mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-24 22:39:31 +08:00
feat(layer4): agent-space distance metric + TCAS-style collision avoidance (v0)
The moat layer: spatial deconfliction for multiple agents (and humans) on one codebase, modeled on aircraft TCAS — measure how close two agents are in code-space, then transmit-intent (Traffic Advisory) and steer-away (Resolution Advisory) before they collide at the git layer. scripts/lib/agent-proximity/: - distance.js — the math: per-channel collision probabilities combined via noisy-OR R = 1 - Π(1 - ω·r). Channels: edit overlap (file + line-range Jaccard), dependency coupling (γ^(d-1) over the import graph, direction- agnostic — catches 'edit there breaks here' even when tree-distant), and tree proximity (LCA-based, soft prior). TCAS advise(): clear / advisory(transmit) / resolution(steer), with deterministic right-of-way priority so the maneuver is coordinated. closureRate() for approach-speed escalation. - graph.js — lightweight require/import dependency-graph builder (fs or in-memory). - index.js — scanAirspace(): pairwise advisories + 3D vector embedding (space- filling path embedding pulled toward dependency neighbours) so a 'where are the agents' visualization can render the file-cloud and watch agents crawl / steer. docs/design/agent-proximity.md — full mathematical formulation + protocol + viz + roadmap (v1 call-graph/symbol channels + live session-diff wiring; v2 cross- machine airspace over Tailscale, the zero-conflict-swarm demo). 17 tests; full suite 2869/2869; lint green.
This commit is contained in:
parent
34faa39bd3
commit
726972d735
147
docs/design/agent-proximity.md
Normal file
147
docs/design/agent-proximity.md
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
# Agent-space distance metric & collision avoidance (Layer 4)
|
||||||
|
|
||||||
|
> Status: v0 implemented in `scripts/lib/agent-proximity/`. This is the moat
|
||||||
|
> layer of ECC 2.0 — *spatial deconfliction for multiple agents (and humans)
|
||||||
|
> working the same codebase*, modeled on aircraft collision avoidance (TCAS).
|
||||||
|
|
||||||
|
## The analogy
|
||||||
|
|
||||||
|
Two aircraft sharing airspace don't wait until they touch — TCAS continuously
|
||||||
|
measures their separation and closure rate, issues a **Traffic Advisory** ("there
|
||||||
|
is traffic near you") and then a coordinated **Resolution Advisory** ("you climb,
|
||||||
|
the other descends"). We want the same for agents: a continuous notion of *how
|
||||||
|
close two agents are in code-space*, so that as they approach we fire a trigger
|
||||||
|
that makes them **transmit what they're doing** to each other and, if needed,
|
||||||
|
makes one **steer away** — before they collide at the git/merge layer.
|
||||||
|
|
||||||
|
## 1. Agent state
|
||||||
|
|
||||||
|
At time *t*, agent *a* has a **working set**
|
||||||
|
|
||||||
|
```
|
||||||
|
W_a = { (f, R_f, w_f) } (1)
|
||||||
|
```
|
||||||
|
|
||||||
|
where *f* is a touched file, *R_f* the set of edited line ranges in *f*, and
|
||||||
|
*w_f ∈ (0,1]* a recency weight (older edits decay toward a floor). An agent may
|
||||||
|
also declare an **intent set** *I_a* of files it is about to touch (look-ahead).
|
||||||
|
|
||||||
|
## 2. Collision is multi-channel (noisy-OR)
|
||||||
|
|
||||||
|
Two agents can collide through several independent channels. Each channel *i*
|
||||||
|
yields a collision probability *r_i ∈ [0,1]*; we combine them as the probability
|
||||||
|
of colliding through **at least one** channel:
|
||||||
|
|
||||||
|
```
|
||||||
|
R(a,b) = 1 − Π_i ( 1 − ω_i · r_i ) (2)
|
||||||
|
```
|
||||||
|
|
||||||
|
with channel weights *ω_i ∈ [0,1]*. The reported **distance** is the dual
|
||||||
|
*D(a,b) = 1 − R(a,b)*.
|
||||||
|
|
||||||
|
### Channel 1 — edit overlap *r_overlap*
|
||||||
|
|
||||||
|
For shared files *S = files(W_a) ∩ files(W_b)*:
|
||||||
|
|
||||||
|
```
|
||||||
|
lineOverlap(f) = |R_f^a ∩ R_f^b| / |R_f^a ∪ R_f^b|
|
||||||
|
r_overlap = max( Jaccard(files_a, files_b), max_{f∈S} lineOverlap(f) ) (3)
|
||||||
|
```
|
||||||
|
|
||||||
|
Same file, overlapping lines ⇒ imminent collision (*r_overlap → 1*).
|
||||||
|
|
||||||
|
### Channel 2 — dependency coupling *r_dep*
|
||||||
|
|
||||||
|
Build a dependency graph *G=(V,E)*, edge *f→g* iff *f* imports *g*. Even when two
|
||||||
|
files sit in distant subtrees, if one agent edits a file the other imports, the
|
||||||
|
edit breaks the importer. Coupling decays with (direction-agnostic) graph
|
||||||
|
distance *d_G*:
|
||||||
|
|
||||||
|
```
|
||||||
|
coupling(f,g) = γ^{ d_G(f,g) − 1 } γ ∈ (0,1), 0 if unreachable (4)
|
||||||
|
r_dep = max_{f∈W_a, g∈W_b} w_f · w_g · coupling(f,g) (5)
|
||||||
|
```
|
||||||
|
|
||||||
|
A direct import (*d_G = 1*) ⇒ *coupling = 1*. This is the **"collision even when
|
||||||
|
far away"** term the metric must capture — a cross-file parameter/return
|
||||||
|
dependency that fails at a distance.
|
||||||
|
|
||||||
|
### Channel 3 — tree proximity *r_tree* (soft prior)
|
||||||
|
|
||||||
|
For two paths with lowest-common-ancestor depth *L*:
|
||||||
|
|
||||||
|
```
|
||||||
|
treeDistance(f,g) = ((depth_f − L) + (depth_g − L)) / (depth_f + depth_g) (6)
|
||||||
|
r_tree = 1 − min_{f∈W_a, g∈W_b} treeDistance(f,g)
|
||||||
|
```
|
||||||
|
|
||||||
|
(0 = same file, 1 = disjoint roots.) Tree proximity alone rarely causes a
|
||||||
|
collision, so *ω_tree* is small — it nudges the metric, never dominates it.
|
||||||
|
|
||||||
|
### Future channels (same shape)
|
||||||
|
|
||||||
|
Call-graph distance (two functions near in the call stack), symbol-level
|
||||||
|
read/write hazard (a writes a symbol b reads), and test-coverage overlap all slot
|
||||||
|
in as additional *r_i* with their own weights — the noisy-OR (2) absorbs them
|
||||||
|
without changing the framework.
|
||||||
|
|
||||||
|
## 3. The TCAS protocol
|
||||||
|
|
||||||
|
Two thresholds carve a protected zone around *R*:
|
||||||
|
|
||||||
|
| Risk band | Advisory | Action |
|
||||||
|
|---|---|---|
|
||||||
|
| `R < τ_TA` | **Clear** | nothing |
|
||||||
|
| `τ_TA ≤ R < τ_RA` | **Traffic Advisory** | both agents **transmit intent** to each other (the scout handshake — "here is what I'm doing / did") |
|
||||||
|
| `R ≥ τ_RA` | **Resolution Advisory** | the **lower-priority** agent steers away; the other holds course |
|
||||||
|
|
||||||
|
The resolution is **coordinated and deterministic** (like one plane climbing while
|
||||||
|
the other descends) so the two agents never pick the same maneuver. Right-of-way
|
||||||
|
priority:
|
||||||
|
|
||||||
|
```
|
||||||
|
priority(a) = ( committed-work(a), age(a) ) lexicographic
|
||||||
|
```
|
||||||
|
|
||||||
|
More committed work wins; ties break on earlier start; the final tiebreak is a
|
||||||
|
stable agent id. The lower-priority agent receives the steer.
|
||||||
|
|
||||||
|
**Closure rate.** TCAS escalates on *closing speed*, not just separation. From two
|
||||||
|
risk samples Δt apart, `closureRate = (R_t − R_{t−Δt}) / Δt`; a positive closure
|
||||||
|
rate near *τ_TA* can pre-emptively escalate before the protected zone is entered.
|
||||||
|
|
||||||
|
## 4. Vector-space view (the visualization)
|
||||||
|
|
||||||
|
Each file gets a coordinate via a **space-filling embedding of its path** (files
|
||||||
|
sharing a long directory prefix share most of their coordinate), then pulled
|
||||||
|
toward its dependency neighbours by one averaging step. An agent sits at the
|
||||||
|
recency-weighted centroid of its files' coordinates. The result: `‖v_a − v_b‖`
|
||||||
|
tracks the collision risk *R*, so a **3D "where are the agents" view** renders
|
||||||
|
agents as moving points in a file-cloud — you literally watch them crawl toward
|
||||||
|
each other, see the advisory line light up, and watch one steer away.
|
||||||
|
|
||||||
|
`scanAirspace(agents, graph)` returns, in one pass: the non-clear `advisories`
|
||||||
|
(what the trigger layer acts on), the 3D `positions` and `fileCoordinates` (what
|
||||||
|
the renderer draws), and pairwise `links` with risk (the edges to color).
|
||||||
|
|
||||||
|
## 5. How it wires into ECC
|
||||||
|
|
||||||
|
- **Inputs** come from the session/work state: each running session's worktree
|
||||||
|
diff gives its working set *W_a*; the dependency graph is built from the repo
|
||||||
|
(`buildDependencyGraph`).
|
||||||
|
- **Triggers**: the control-pane tick calls `scanAirspace`; a Traffic Advisory
|
||||||
|
injects a "transmit intent" message between the two agents' sessions; a
|
||||||
|
Resolution Advisory tells the lower-priority agent to steer (re-target to a
|
||||||
|
different file/subtree) — the first concrete realization of *just-in-time
|
||||||
|
multi-agent (and multi-human) deconfliction*.
|
||||||
|
- **Board**: advisories surface on the kanban as proximity warnings, extending
|
||||||
|
the agent/human JIT assignment layer already in the control pane.
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
- v0 (done): tree + overlap + dependency channels, noisy-OR risk, TCAS advisories,
|
||||||
|
priority/steer, 3D embedding, full test coverage.
|
||||||
|
- v1: call-graph & symbol read/write channels; intent look-ahead; closure-rate
|
||||||
|
escalation wired to live session diffs.
|
||||||
|
- v2: cross-machine airspace over Tailscale (teammate agents enter the same
|
||||||
|
space); the recorded "N agents, M humans, zero merge conflicts" demo.
|
||||||
326
scripts/lib/agent-proximity/distance.js
Normal file
326
scripts/lib/agent-proximity/distance.js
Normal file
@ -0,0 +1,326 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent-space distance metric + collision avoidance (ECC 2.0, Layer 4 v0).
|
||||||
|
*
|
||||||
|
* Two agents editing the same codebase are like two aircraft sharing airspace:
|
||||||
|
* we want a continuous notion of "how close are they" so that, as they approach,
|
||||||
|
* we fire a TCAS-style protocol — first a Traffic Advisory (exchange intent),
|
||||||
|
* then a Resolution Advisory (one steers away) — *before* they collide at the
|
||||||
|
* git/merge layer.
|
||||||
|
*
|
||||||
|
* ── The state of an agent ──────────────────────────────────────────────────
|
||||||
|
* At time t, agent a has a working set
|
||||||
|
* W_a = { (f, R_f, w_f) } (1)
|
||||||
|
* where f is a file it has touched, R_f the set of line ranges it edited in f,
|
||||||
|
* and w_f ∈ (0,1] a recency weight (older edits decay). Optionally an agent
|
||||||
|
* declares an intent set I_a of files it is about to touch.
|
||||||
|
*
|
||||||
|
* ── Collision is multi-channel ─────────────────────────────────────────────
|
||||||
|
* Two agents can collide through several independent channels, so we model a
|
||||||
|
* per-channel collision probability r_i ∈ [0,1] and combine with a noisy-OR
|
||||||
|
* (probability of colliding through *at least one* channel):
|
||||||
|
* R(a,b) = 1 − Π_i (1 − ω_i · r_i) (2)
|
||||||
|
* with channel weights ω_i ∈ [0,1]. R is the agent-distance's dual: we report
|
||||||
|
* both the risk R ∈ [0,1] and a distance D = 1 − R.
|
||||||
|
*
|
||||||
|
* Channels (each defined below):
|
||||||
|
* r_overlap — same file / overlapping line ranges (imminent)
|
||||||
|
* r_dep — one agent's files depend on the other's (collision even when
|
||||||
|
* far apart in the tree: edit there breaks here)
|
||||||
|
* r_tree — proximity in the directory tree (a soft prior)
|
||||||
|
*
|
||||||
|
* ── Channel 1: edit overlap ────────────────────────────────────────────────
|
||||||
|
* For the shared files S = files(W_a) ∩ files(W_b):
|
||||||
|
* - same file, no line info → Jaccard of the file sets is the floor.
|
||||||
|
* - same file with line ranges → fraction of overlapping lines:
|
||||||
|
* lineOverlap(f) = |R_f^a ∩ R_f^b| / |R_f^a ∪ R_f^b|.
|
||||||
|
* r_overlap = max( jaccard(files_a, files_b), max_{f∈S} lineOverlap(f) ). (3)
|
||||||
|
*
|
||||||
|
* ── Channel 2: dependency coupling ─────────────────────────────────────────
|
||||||
|
* Build a directed dependency graph G=(V,E), V=files, edge f→g iff f imports g.
|
||||||
|
* Even if f and g are in distant subtrees, if f (agent a) depends on g (agent b)
|
||||||
|
* then b's edit to g can break a. Coupling decays with graph distance:
|
||||||
|
* coupling(f,g) = γ^{ d_G(f,g) − 1 } (γ∈(0,1)), 0 if unreachable. (4)
|
||||||
|
* A direct edge (d_G=1) ⇒ coupling=1. We take the recency-weighted max over
|
||||||
|
* cross pairs:
|
||||||
|
* r_dep = max_{f∈W_a, g∈W_b} w_f·w_g·max(coupling(f,g), coupling(g,f)). (5)
|
||||||
|
*
|
||||||
|
* ── Channel 3: tree proximity ──────────────────────────────────────────────
|
||||||
|
* For two paths split into segments with lowest-common-ancestor depth L:
|
||||||
|
* treeDistance(f,g) = ((depth_f − L) + (depth_g − L)) / (depth_f + depth_g) (6)
|
||||||
|
* (0 = same file, 1 = disjoint roots). r_tree = 1 − min cross-pair treeDist.
|
||||||
|
* Tree proximity alone rarely causes a collision, so ω_tree is small — it nudges
|
||||||
|
* the metric, it does not dominate it.
|
||||||
|
*
|
||||||
|
* ── TCAS protocol ──────────────────────────────────────────────────────────
|
||||||
|
* Two thresholds carve a protected zone:
|
||||||
|
* R < τ_TA → CLEAR
|
||||||
|
* τ_TA ≤ R < τ_RA → TRAFFIC ADVISORY: each agent transmits what it is
|
||||||
|
* doing/has done to the other (the scout handshake)
|
||||||
|
* R ≥ τ_RA → RESOLUTION ADVISORY: the lower-priority agent steers
|
||||||
|
* away; the higher-priority one holds course.
|
||||||
|
* Like TCAS coordinating climb/descend, the resolution is *coordinated* and
|
||||||
|
* deterministic so both agents never pick the same maneuver: priority(a) breaks
|
||||||
|
* the tie (right-of-way to the agent with more committed work / earlier start;
|
||||||
|
* stable agentId as the final tiebreak). See advise().
|
||||||
|
*
|
||||||
|
* ── Vector-space view ──────────────────────────────────────────────────────
|
||||||
|
* embedAgent() places each agent at the recency-weighted centroid of its files'
|
||||||
|
* coordinates, where a file's coordinate is a low-dim hash of its path segments
|
||||||
|
* smoothed toward its dependency neighbours. Then ‖v_a − v_b‖ tracks R, which is
|
||||||
|
* what a 3D "where are the agents" visualization renders. See embed.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
channelWeights: { overlap: 1.0, dependency: 0.9, tree: 0.25 },
|
||||||
|
depDecay: 0.5, // γ in (4)
|
||||||
|
recencyFloor: 0.15, // weight never decays below this so stale-but-relevant files still count
|
||||||
|
thresholds: { ta: 0.35, ra: 0.7 } // τ_TA, τ_RA
|
||||||
|
};
|
||||||
|
|
||||||
|
function clamp01(x) {
|
||||||
|
if (!Number.isFinite(x)) return 0;
|
||||||
|
return x < 0 ? 0 : x > 1 ? 1 : x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePath(p) {
|
||||||
|
return String(p || '')
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
.replace(/^\.\//, '')
|
||||||
|
.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function segments(p) {
|
||||||
|
return normalizePath(p).split('/').filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tree distance ∈ [0,1] between two file paths — eq. (6). 0 = same file.
|
||||||
|
*/
|
||||||
|
function treeDistance(a, b) {
|
||||||
|
const sa = segments(a);
|
||||||
|
const sb = segments(b);
|
||||||
|
if (sa.length === 0 || sb.length === 0) return 1;
|
||||||
|
let lca = 0;
|
||||||
|
while (lca < sa.length && lca < sb.length && sa[lca] === sb[lca]) lca += 1;
|
||||||
|
const da = sa.length;
|
||||||
|
const db = sb.length;
|
||||||
|
if (da === db && lca === da) return 0; // identical path
|
||||||
|
return clamp01((da - lca + (db - lca)) / (da + db));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Line-range overlap fraction (Jaccard over covered lines) for two range lists.
|
||||||
|
* Each range is [start, end] inclusive. Empty/absent ranges ⇒ treat the whole
|
||||||
|
* file as touched, so two file-level edits to the same file count as full overlap.
|
||||||
|
*/
|
||||||
|
function lineRangeOverlap(rangesA, rangesB) {
|
||||||
|
const a = Array.isArray(rangesA) ? rangesA : [];
|
||||||
|
const b = Array.isArray(rangesB) ? rangesB : [];
|
||||||
|
if (a.length === 0 || b.length === 0) return 1; // file-level edit ⇒ whole-file overlap
|
||||||
|
const covered = ranges => {
|
||||||
|
const set = new Set();
|
||||||
|
for (const [s, e] of ranges) {
|
||||||
|
const lo = Math.min(s, e);
|
||||||
|
const hi = Math.max(s, e);
|
||||||
|
for (let i = lo; i <= hi; i += 1) set.add(i);
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
};
|
||||||
|
const ca = covered(a);
|
||||||
|
const cb = covered(b);
|
||||||
|
let inter = 0;
|
||||||
|
for (const v of ca) if (cb.has(v)) inter += 1;
|
||||||
|
const union = ca.size + cb.size - inter;
|
||||||
|
return union === 0 ? 0 : inter / union;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileSet(workingSet) {
|
||||||
|
return new Set((workingSet.files || []).map(f => normalizePath(f.path)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function jaccard(setA, setB) {
|
||||||
|
if (setA.size === 0 && setB.size === 0) return 0;
|
||||||
|
let inter = 0;
|
||||||
|
for (const v of setA) if (setB.has(v)) inter += 1;
|
||||||
|
const union = setA.size + setB.size - inter;
|
||||||
|
return union === 0 ? 0 : inter / union;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel 1 — edit overlap, eq. (3).
|
||||||
|
*/
|
||||||
|
function overlapRisk(a, b) {
|
||||||
|
const filesA = a.files || [];
|
||||||
|
const filesB = b.files || [];
|
||||||
|
const setA = fileSet(a);
|
||||||
|
const setB = fileSet(b);
|
||||||
|
let r = jaccard(setA, setB);
|
||||||
|
const byPathB = new Map(filesB.map(f => [normalizePath(f.path), f]));
|
||||||
|
for (const fa of filesA) {
|
||||||
|
const fb = byPathB.get(normalizePath(fa.path));
|
||||||
|
if (fb) {
|
||||||
|
const w = (fa.weight ?? 1) * (fb.weight ?? 1);
|
||||||
|
r = Math.max(r, w * lineRangeOverlap(fa.lines, fb.lines));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clamp01(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shortest-path distance in a directed dependency graph, treated as undirected
|
||||||
|
* for reachability (a depends-on edge couples both endpoints). BFS, capped.
|
||||||
|
*/
|
||||||
|
function graphDistance(graph, from, to, cap = 6) {
|
||||||
|
const start = normalizePath(from);
|
||||||
|
const goal = normalizePath(to);
|
||||||
|
if (start === goal) return 0;
|
||||||
|
const adj = graph && graph.adjacency ? graph.adjacency : graph || {};
|
||||||
|
const seen = new Set([start]);
|
||||||
|
let frontier = [start];
|
||||||
|
for (let depth = 1; depth <= cap; depth += 1) {
|
||||||
|
const next = [];
|
||||||
|
for (const node of frontier) {
|
||||||
|
const neighbours = adj[node] || [];
|
||||||
|
for (const nb of neighbours) {
|
||||||
|
const n = normalizePath(nb);
|
||||||
|
if (n === goal) return depth;
|
||||||
|
if (!seen.has(n)) {
|
||||||
|
seen.add(n);
|
||||||
|
next.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (next.length === 0) break;
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel 2 — dependency coupling, eqs. (4)-(5).
|
||||||
|
*/
|
||||||
|
function dependencyRisk(a, b, graph, opts = {}) {
|
||||||
|
const decay = opts.depDecay ?? DEFAULTS.depDecay;
|
||||||
|
const filesA = a.files || [];
|
||||||
|
const filesB = b.files || [];
|
||||||
|
let r = 0;
|
||||||
|
for (const fa of filesA) {
|
||||||
|
for (const fb of filesB) {
|
||||||
|
// A depends-on edge couples both endpoints, so use the smaller of the two
|
||||||
|
// directed distances (importer→imported or imported→importer).
|
||||||
|
const d = Math.min(graphDistance(graph, fa.path, fb.path), graphDistance(graph, fb.path, fa.path));
|
||||||
|
if (d === Infinity || d === 0) continue;
|
||||||
|
const coupling = Math.pow(decay, d - 1); // γ^{d-1}
|
||||||
|
const w = (fa.weight ?? 1) * (fb.weight ?? 1);
|
||||||
|
r = Math.max(r, w * coupling);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clamp01(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Channel 3 — tree proximity (soft prior), eq. (6).
|
||||||
|
*/
|
||||||
|
function treeRisk(a, b) {
|
||||||
|
const filesA = a.files || [];
|
||||||
|
const filesB = b.files || [];
|
||||||
|
let minDist = 1;
|
||||||
|
for (const fa of filesA) {
|
||||||
|
for (const fb of filesB) {
|
||||||
|
minDist = Math.min(minDist, treeDistance(fa.path, fb.path));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return clamp01(1 - minDist);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collision risk R(a,b) ∈ [0,1] via the noisy-OR of channels, eq. (2).
|
||||||
|
* Returns the risk, its dual distance, and the per-channel breakdown.
|
||||||
|
*/
|
||||||
|
function collisionRisk(a, b, graph = {}, options = {}) {
|
||||||
|
const weights = { ...DEFAULTS.channelWeights, ...(options.channelWeights || {}) };
|
||||||
|
const channels = {
|
||||||
|
overlap: overlapRisk(a, b),
|
||||||
|
dependency: dependencyRisk(a, b, graph, options),
|
||||||
|
tree: treeRisk(a, b)
|
||||||
|
};
|
||||||
|
let product = 1;
|
||||||
|
for (const key of Object.keys(channels)) {
|
||||||
|
const w = clamp01(weights[key] ?? 0);
|
||||||
|
product *= 1 - w * channels[key];
|
||||||
|
}
|
||||||
|
const risk = clamp01(1 - product);
|
||||||
|
return { risk, distance: clamp01(1 - risk), channels };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Right-of-way priority: the agent with more committed work and the earlier
|
||||||
|
* start holds course; the other steers. Higher number = higher priority.
|
||||||
|
*/
|
||||||
|
function agentPriority(agent) {
|
||||||
|
const progress = (agent.files || []).reduce((s, f) => s + (f.weight ?? 1), 0);
|
||||||
|
const startedAt = agent.startedAt ? Date.parse(agent.startedAt) || 0 : 0;
|
||||||
|
// Earlier start ⇒ larger right-of-way term (negative ms, so earlier = larger).
|
||||||
|
return { progress, ageMs: startedAt ? Date.now() - startedAt : 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TCAS-style advisory between two agents given their collision risk.
|
||||||
|
* Returns { level: 'clear'|'advisory'|'resolution', risk, transmit, steer, hold }.
|
||||||
|
* - advisory: both should transmit intent to each other.
|
||||||
|
* - resolution: `steer` is the agentId that must move; `hold` holds course.
|
||||||
|
*/
|
||||||
|
function advise(a, b, graph = {}, options = {}) {
|
||||||
|
const thresholds = { ...DEFAULTS.thresholds, ...(options.thresholds || {}) };
|
||||||
|
const { risk, channels, distance } = collisionRisk(a, b, graph, options);
|
||||||
|
|
||||||
|
if (risk < thresholds.ta) {
|
||||||
|
return { level: 'clear', risk, distance, channels, transmit: false, steer: null, hold: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const pa = agentPriority(a);
|
||||||
|
const pb = agentPriority(b);
|
||||||
|
// Right-of-way: more progress wins; tie → earlier start (greater age) wins;
|
||||||
|
// final deterministic tiebreak on agentId so the maneuver is coordinated.
|
||||||
|
let aHasPriority;
|
||||||
|
if (pa.progress !== pb.progress) aHasPriority = pa.progress > pb.progress;
|
||||||
|
else if (pa.ageMs !== pb.ageMs) aHasPriority = pa.ageMs > pb.ageMs;
|
||||||
|
else aHasPriority = String(a.agentId) < String(b.agentId);
|
||||||
|
|
||||||
|
const hold = aHasPriority ? a.agentId : b.agentId;
|
||||||
|
const steer = aHasPriority ? b.agentId : a.agentId;
|
||||||
|
|
||||||
|
if (risk < thresholds.ra) {
|
||||||
|
// Traffic advisory: exchange intent, no one has to move yet.
|
||||||
|
return { level: 'advisory', risk, distance, channels, transmit: true, steer: null, hold: null };
|
||||||
|
}
|
||||||
|
// Resolution advisory: the lower-priority agent steers away.
|
||||||
|
return { level: 'resolution', risk, distance, channels, transmit: true, steer, hold };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closure rate: how fast two agents are converging, from two risk samples
|
||||||
|
* Δt apart (TCAS uses closure rate, not just separation, to decide urgency).
|
||||||
|
* Positive ⇒ approaching. Used to escalate before the protected zone is reached.
|
||||||
|
*/
|
||||||
|
function closureRate(prevRisk, currRisk, dtMs) {
|
||||||
|
const dt = Number(dtMs) > 0 ? Number(dtMs) : 1;
|
||||||
|
return (clamp01(currRisk) - clamp01(prevRisk)) / (dt / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULTS,
|
||||||
|
treeDistance,
|
||||||
|
lineRangeOverlap,
|
||||||
|
graphDistance,
|
||||||
|
overlapRisk,
|
||||||
|
dependencyRisk,
|
||||||
|
treeRisk,
|
||||||
|
collisionRisk,
|
||||||
|
agentPriority,
|
||||||
|
advise,
|
||||||
|
closureRate,
|
||||||
|
_internal: { normalizePath, segments, jaccard }
|
||||||
|
};
|
||||||
140
scripts/lib/agent-proximity/graph.js
Normal file
140
scripts/lib/agent-proximity/graph.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight dependency-graph builder for the agent-proximity metric.
|
||||||
|
*
|
||||||
|
* Edge f → g iff f imports/requires g. This is the structure the dependency
|
||||||
|
* channel (distance.js, eqs. 4-5) walks: two agents far apart in the tree still
|
||||||
|
* collide if one edits a file the other imports.
|
||||||
|
*
|
||||||
|
* v0 scans JS/TS `require()` / `import ... from` / `import(...)` for relative
|
||||||
|
* specifiers and resolves them to repo-relative paths. It is intentionally
|
||||||
|
* static and dependency-free; richer languages and call-graph edges are future
|
||||||
|
* channels that slot into the same adjacency shape.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const SOURCE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx'];
|
||||||
|
const RESOLVE_EXTENSIONS = ['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.json'];
|
||||||
|
|
||||||
|
function toRepoRel(repoRoot, absPath) {
|
||||||
|
return path.relative(repoRoot, absPath).split(path.sep).join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match relative specifiers only (./ or ../). Bare specifiers are node_modules
|
||||||
|
// and never the target of an in-repo collision.
|
||||||
|
const SPEC_PATTERNS = [
|
||||||
|
/require\(\s*['"](\.[^'"]+)['"]\s*\)/g,
|
||||||
|
/import\s+(?:[^'"]*?\s+from\s+)?['"](\.[^'"]+)['"]/g,
|
||||||
|
/import\(\s*['"](\.[^'"]+)['"]\s*\)/g,
|
||||||
|
/export\s+(?:\*|\{[^}]*\})\s+from\s+['"](\.[^'"]+)['"]/g
|
||||||
|
];
|
||||||
|
|
||||||
|
function extractRelativeSpecifiers(source) {
|
||||||
|
const specs = new Set();
|
||||||
|
for (const re of SPEC_PATTERNS) {
|
||||||
|
re.lastIndex = 0;
|
||||||
|
let m;
|
||||||
|
while ((m = re.exec(source)) !== null) {
|
||||||
|
specs.add(m[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...specs];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a relative specifier from `fromFile` to a repo-relative path, trying
|
||||||
|
* extension and /index resolution like Node/TS would.
|
||||||
|
*/
|
||||||
|
function resolveSpecifier(repoRoot, fromFile, spec) {
|
||||||
|
const baseDir = path.dirname(path.join(repoRoot, fromFile));
|
||||||
|
const target = path.resolve(baseDir, spec);
|
||||||
|
const candidates = [target];
|
||||||
|
for (const ext of RESOLVE_EXTENSIONS) candidates.push(target + ext);
|
||||||
|
for (const ext of RESOLVE_EXTENSIONS) candidates.push(path.join(target, 'index' + ext));
|
||||||
|
for (const cand of candidates) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(cand) && fs.statSync(cand).isFile()) {
|
||||||
|
return toRepoRel(repoRoot, cand);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore unreadable candidate */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSourceFile(p) {
|
||||||
|
return SOURCE_EXTENSIONS.includes(path.extname(p));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a dependency graph from an explicit list of repo-relative files.
|
||||||
|
* Returns { adjacency: { file: [importedFile, ...] }, files: [...] }.
|
||||||
|
*
|
||||||
|
* @param {string} repoRoot
|
||||||
|
* @param {string[]} files repo-relative paths to scan
|
||||||
|
* @param {object} [deps] injectable fs for testing: { readFileSync, existsSync, statSync }
|
||||||
|
*/
|
||||||
|
function buildDependencyGraph(repoRoot, files, deps = {}) {
|
||||||
|
const read = deps.readFileSync || fs.readFileSync;
|
||||||
|
const adjacency = {};
|
||||||
|
const scanned = [];
|
||||||
|
for (const rel of files || []) {
|
||||||
|
const normalized = String(rel).replace(/\\/g, '/');
|
||||||
|
if (!isSourceFile(normalized)) continue;
|
||||||
|
scanned.push(normalized);
|
||||||
|
let source = '';
|
||||||
|
try {
|
||||||
|
source = String(read(path.join(repoRoot, normalized), 'utf8'));
|
||||||
|
} catch {
|
||||||
|
adjacency[normalized] = adjacency[normalized] || [];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const edges = new Set(adjacency[normalized] || []);
|
||||||
|
for (const spec of extractRelativeSpecifiers(source)) {
|
||||||
|
const resolved = resolveSpecifier(repoRoot, normalized, spec);
|
||||||
|
if (resolved && resolved !== normalized) edges.add(resolved);
|
||||||
|
}
|
||||||
|
adjacency[normalized] = [...edges];
|
||||||
|
}
|
||||||
|
return { adjacency, files: scanned };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a graph directly from an in-memory map of { file: sourceText }, for
|
||||||
|
* callers that already have file contents (and for tests). Specifiers are
|
||||||
|
* resolved against the provided file set rather than the filesystem.
|
||||||
|
*/
|
||||||
|
function buildDependencyGraphFromSources(sources = {}) {
|
||||||
|
const adjacency = {};
|
||||||
|
const fileList = Object.keys(sources).map(f => f.replace(/\\/g, '/'));
|
||||||
|
const fileSet = new Set(fileList);
|
||||||
|
const tryResolve = (fromFile, spec) => {
|
||||||
|
const base = path.posix.dirname(fromFile);
|
||||||
|
const target = path.posix.normalize(path.posix.join(base, spec));
|
||||||
|
const candidates = [target];
|
||||||
|
for (const ext of RESOLVE_EXTENSIONS) candidates.push(target + ext);
|
||||||
|
for (const ext of RESOLVE_EXTENSIONS) candidates.push(path.posix.join(target, 'index' + ext));
|
||||||
|
return candidates.find(c => fileSet.has(c)) || null;
|
||||||
|
};
|
||||||
|
for (const file of fileList) {
|
||||||
|
const edges = new Set();
|
||||||
|
for (const spec of extractRelativeSpecifiers(String(sources[file] || ''))) {
|
||||||
|
const resolved = tryResolve(file, spec);
|
||||||
|
if (resolved && resolved !== file) edges.add(resolved);
|
||||||
|
}
|
||||||
|
adjacency[file] = [...edges];
|
||||||
|
}
|
||||||
|
return { adjacency, files: fileList };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildDependencyGraph,
|
||||||
|
buildDependencyGraphFromSources,
|
||||||
|
extractRelativeSpecifiers,
|
||||||
|
resolveSpecifier,
|
||||||
|
isSourceFile
|
||||||
|
};
|
||||||
170
scripts/lib/agent-proximity/index.js
Normal file
170
scripts/lib/agent-proximity/index.js
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Agent-proximity orchestration: scan all agents in a codebase, compute the
|
||||||
|
* pairwise TCAS advisories that drive the steer/transmit triggers, and embed
|
||||||
|
* each agent in 3D space for the "where are the agents" visualization.
|
||||||
|
*
|
||||||
|
* This is the call the control pane / hook layer makes each tick:
|
||||||
|
* const scan = scanAirspace(agents, graph)
|
||||||
|
* for (const a of scan.advisories) fireTrigger(a) // transmit / steer
|
||||||
|
* renderViz(scan.positions, scan.advisories) // 3D crawl view
|
||||||
|
*/
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { advise, collisionRisk, DEFAULTS } = require('./distance');
|
||||||
|
const { buildDependencyGraph, buildDependencyGraphFromSources } = require('./graph');
|
||||||
|
|
||||||
|
const { normalizePath, segments } = require('./distance')._internal;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deterministic hash of a string to a unit-ish vector in R^dims (components in
|
||||||
|
* roughly [-1, 1]). Used to place tree prefixes in space.
|
||||||
|
*/
|
||||||
|
function hashVec(str, dims) {
|
||||||
|
const digest = crypto.createHash('sha256').update(String(str)).digest();
|
||||||
|
const v = new Array(dims).fill(0);
|
||||||
|
for (let d = 0; d < dims; d += 1) {
|
||||||
|
// Two bytes per dim → [-1, 1).
|
||||||
|
const hi = digest[(d * 2) % digest.length];
|
||||||
|
const lo = digest[(d * 2 + 1) % digest.length];
|
||||||
|
v[d] = ((hi << 8) | lo) / 32768 - 1;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coordinate of a file: a space-filling embedding of its path. Files that share
|
||||||
|
* a long directory prefix share most of their coordinate (deeper segments
|
||||||
|
* perturb less), so tree-close files are space-close — exactly what eq. (6)
|
||||||
|
* wants the visualization to show.
|
||||||
|
*/
|
||||||
|
function fileCoordinate(filePath, dims = 3) {
|
||||||
|
const segs = segments(filePath);
|
||||||
|
const v = new Array(dims).fill(0);
|
||||||
|
let prefix = '';
|
||||||
|
for (let i = 0; i < segs.length; i += 1) {
|
||||||
|
prefix += '/' + segs[i];
|
||||||
|
const h = hashVec(prefix, dims);
|
||||||
|
const scale = 1 / Math.pow(2, i);
|
||||||
|
for (let d = 0; d < dims; d += 1) v[d] += h[d] * scale;
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull a file's coordinate toward the coordinates of its dependency neighbours
|
||||||
|
* (one averaging step), so coupled files that are far in the tree are drawn
|
||||||
|
* closer in space — the dependency channel made visible.
|
||||||
|
*/
|
||||||
|
function smoothByDependency(coords, graph, alpha = 0.35) {
|
||||||
|
const adj = (graph && graph.adjacency) || {};
|
||||||
|
const out = {};
|
||||||
|
for (const file of Object.keys(coords)) {
|
||||||
|
const base = coords[file];
|
||||||
|
const neighbours = (adj[file] || []).map(normalizePath).filter(n => coords[n]);
|
||||||
|
if (neighbours.length === 0) {
|
||||||
|
out[file] = base.slice();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const dims = base.length;
|
||||||
|
const avg = new Array(dims).fill(0);
|
||||||
|
for (const n of neighbours) for (let d = 0; d < dims; d += 1) avg[d] += coords[n][d];
|
||||||
|
for (let d = 0; d < dims; d += 1) avg[d] /= neighbours.length;
|
||||||
|
out[file] = base.map((x, d) => (1 - alpha) * x + alpha * avg[d]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function weightedCentroid(files, fileCoords, dims) {
|
||||||
|
const v = new Array(dims).fill(0);
|
||||||
|
let wsum = 0;
|
||||||
|
for (const f of files) {
|
||||||
|
const c = fileCoords[normalizePath(f.path)];
|
||||||
|
if (!c) continue;
|
||||||
|
const w = f.weight ?? 1;
|
||||||
|
for (let d = 0; d < dims; d += 1) v[d] += c[d] * w;
|
||||||
|
wsum += w;
|
||||||
|
}
|
||||||
|
if (wsum > 0) for (let d = 0; d < dims; d += 1) v[d] /= wsum;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embed agents in R^dims for visualization. Returns one position per agent plus
|
||||||
|
* the file coordinates used, so a renderer can draw both the agents and the
|
||||||
|
* file-cloud they sit in.
|
||||||
|
*/
|
||||||
|
function embedAgents(agents, graph = {}, options = {}) {
|
||||||
|
const dims = options.dims || 3;
|
||||||
|
const fileCoords = {};
|
||||||
|
for (const agent of agents) {
|
||||||
|
for (const f of agent.files || []) {
|
||||||
|
const p = normalizePath(f.path);
|
||||||
|
if (!fileCoords[p]) fileCoords[p] = fileCoordinate(p, dims);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const smoothed = smoothByDependency(fileCoords, graph, options.dependencyPull ?? 0.35);
|
||||||
|
const positions = agents.map(agent => ({
|
||||||
|
agentId: agent.agentId,
|
||||||
|
position: weightedCentroid(agent.files || [], smoothed, dims),
|
||||||
|
fileCount: (agent.files || []).length
|
||||||
|
}));
|
||||||
|
return { dims, positions, fileCoordinates: smoothed };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the whole airspace: pairwise advisories + 3D positions in one pass.
|
||||||
|
*
|
||||||
|
* @param {Array<{agentId,files,startedAt?,intent?}>} agents
|
||||||
|
* @param {object} graph dependency graph (adjacency)
|
||||||
|
* @param {object} [options]
|
||||||
|
* @returns {{ advisories, positions, links, generatedAt }}
|
||||||
|
*/
|
||||||
|
function scanAirspace(agents, graph = {}, options = {}) {
|
||||||
|
const list = Array.isArray(agents) ? agents.filter(a => a && a.agentId !== null && a.agentId !== undefined) : [];
|
||||||
|
const advisories = [];
|
||||||
|
const links = [];
|
||||||
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
|
for (let j = i + 1; j < list.length; j += 1) {
|
||||||
|
const a = list[i];
|
||||||
|
const b = list[j];
|
||||||
|
const verdict = advise(a, b, graph, options);
|
||||||
|
links.push({
|
||||||
|
a: a.agentId,
|
||||||
|
b: b.agentId,
|
||||||
|
risk: verdict.risk,
|
||||||
|
distance: verdict.distance,
|
||||||
|
level: verdict.level
|
||||||
|
});
|
||||||
|
if (verdict.level !== 'clear') {
|
||||||
|
advisories.push({ a: a.agentId, b: b.agentId, ...verdict });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
advisories.sort((x, y) => y.risk - x.risk);
|
||||||
|
links.sort((x, y) => y.risk - x.risk);
|
||||||
|
const embedding = embedAgents(list, graph, options);
|
||||||
|
return {
|
||||||
|
advisories,
|
||||||
|
positions: embedding.positions,
|
||||||
|
fileCoordinates: embedding.fileCoordinates,
|
||||||
|
links,
|
||||||
|
counts: {
|
||||||
|
agents: list.length,
|
||||||
|
advisories: advisories.length,
|
||||||
|
resolutions: advisories.filter(a => a.level === 'resolution').length
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
DEFAULTS,
|
||||||
|
scanAirspace,
|
||||||
|
embedAgents,
|
||||||
|
fileCoordinate,
|
||||||
|
collisionRisk,
|
||||||
|
advise,
|
||||||
|
buildDependencyGraph,
|
||||||
|
buildDependencyGraphFromSources
|
||||||
|
};
|
||||||
170
tests/lib/agent-proximity.test.js
Normal file
170
tests/lib/agent-proximity.test.js
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Tests for the agent-space distance metric + collision avoidance (Layer 4 v0).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
|
||||||
|
const { treeDistance, lineRangeOverlap, graphDistance, collisionRisk, advise, closureRate } = require('../../scripts/lib/agent-proximity/distance');
|
||||||
|
const { buildDependencyGraphFromSources, extractRelativeSpecifiers } = require('../../scripts/lib/agent-proximity/graph');
|
||||||
|
const { scanAirspace, embedAgents } = require('../../scripts/lib/agent-proximity');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
function test(name, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` PASS ${name}`);
|
||||||
|
passed += 1;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` FAIL ${name}`);
|
||||||
|
console.log(` ${e.message}`);
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function euclid(a, b) {
|
||||||
|
return Math.sqrt(a.reduce((s, x, i) => s + (x - b[i]) ** 2, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== Testing agent-proximity ===\n');
|
||||||
|
|
||||||
|
// ── tree distance ──
|
||||||
|
test('treeDistance: identical path is 0', () => {
|
||||||
|
assert.strictEqual(treeDistance('a/b/c.js', 'a/b/c.js'), 0);
|
||||||
|
});
|
||||||
|
test('treeDistance: siblings are closer than cousins', () => {
|
||||||
|
const sib = treeDistance('src/api/users.js', 'src/api/posts.js');
|
||||||
|
const cousin = treeDistance('src/api/users.js', 'src/db/schema.js');
|
||||||
|
const disjoint = treeDistance('src/api/users.js', 'docs/guide.md');
|
||||||
|
assert.ok(sib < cousin, `siblings ${sib} should be < cousins ${cousin}`);
|
||||||
|
assert.ok(cousin < disjoint, `cousins ${cousin} should be < disjoint ${disjoint}`);
|
||||||
|
assert.ok(sib >= 0 && disjoint <= 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── line overlap ──
|
||||||
|
test('lineRangeOverlap: full overlap when whole-file (no ranges)', () => {
|
||||||
|
assert.strictEqual(lineRangeOverlap([], []), 1);
|
||||||
|
});
|
||||||
|
test('lineRangeOverlap: partial overlapping ranges', () => {
|
||||||
|
const r = lineRangeOverlap([[1, 10]], [[5, 14]]);
|
||||||
|
// overlap lines 5..10 = 6, union 1..14 = 14 → 6/14
|
||||||
|
assert.ok(Math.abs(r - 6 / 14) < 1e-9, `got ${r}`);
|
||||||
|
});
|
||||||
|
test('lineRangeOverlap: disjoint ranges are 0', () => {
|
||||||
|
assert.strictEqual(lineRangeOverlap([[1, 5]], [[20, 25]]), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── dependency graph + distance ──
|
||||||
|
test('builds a dependency graph from require/import sources', () => {
|
||||||
|
const g = buildDependencyGraphFromSources({
|
||||||
|
'src/a.js': "const b = require('./b');\nimport c from './sub/c.js';",
|
||||||
|
'src/b.js': 'module.exports = {};',
|
||||||
|
'src/sub/c.js': 'export default 1;'
|
||||||
|
});
|
||||||
|
assert.deepStrictEqual(new Set(g.adjacency['src/a.js']), new Set(['src/b.js', 'src/sub/c.js']));
|
||||||
|
assert.deepStrictEqual(g.adjacency['src/b.js'], []);
|
||||||
|
});
|
||||||
|
test('extractRelativeSpecifiers ignores bare (node_modules) specifiers', () => {
|
||||||
|
const specs = extractRelativeSpecifiers("require('fs'); require('./local'); import x from 'lodash';");
|
||||||
|
assert.deepStrictEqual(specs, ['./local']);
|
||||||
|
});
|
||||||
|
test('graphDistance: direct edge is 1, two hops is 2, unreachable is Infinity', () => {
|
||||||
|
const g = { adjacency: { 'a.js': ['b.js'], 'b.js': ['c.js'], 'c.js': [], 'z.js': [] } };
|
||||||
|
assert.strictEqual(graphDistance(g, 'a.js', 'b.js'), 1);
|
||||||
|
assert.strictEqual(graphDistance(g, 'a.js', 'c.js'), 2);
|
||||||
|
assert.strictEqual(graphDistance(g, 'a.js', 'z.js'), Infinity);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── collision risk channels ──
|
||||||
|
test('collisionRisk: two agents editing the SAME file ⇒ high risk', () => {
|
||||||
|
const a = { agentId: 'a', files: [{ path: 'src/api/users.js', lines: [[1, 50]] }] };
|
||||||
|
const b = { agentId: 'b', files: [{ path: 'src/api/users.js', lines: [[40, 90]] }] };
|
||||||
|
const { risk, channels } = collisionRisk(a, b, {});
|
||||||
|
assert.ok(risk > 0.5, `same-file risk ${risk} should be high`);
|
||||||
|
assert.ok(channels.overlap > 0);
|
||||||
|
});
|
||||||
|
test('collisionRisk: unrelated far-apart files ⇒ low risk', () => {
|
||||||
|
const a = { agentId: 'a', files: [{ path: 'src/api/users.js' }] };
|
||||||
|
const b = { agentId: 'b', files: [{ path: 'docs/guide.md' }] };
|
||||||
|
const { risk } = collisionRisk(a, b, {});
|
||||||
|
assert.ok(risk < 0.35, `unrelated risk ${risk} should be low`);
|
||||||
|
});
|
||||||
|
test('collisionRisk: dependency edge raises risk even when tree-distant', () => {
|
||||||
|
// a edits a deep util that b's distant file imports.
|
||||||
|
const graph = { adjacency: { 'apps/web/page.js': ['packages/core/util.js'], 'packages/core/util.js': [] } };
|
||||||
|
const a = { agentId: 'a', files: [{ path: 'packages/core/util.js' }] };
|
||||||
|
const b = { agentId: 'b', files: [{ path: 'apps/web/page.js' }] };
|
||||||
|
const coupled = collisionRisk(a, b, graph).risk;
|
||||||
|
const uncoupled = collisionRisk(a, b, {}).risk; // same files, no graph
|
||||||
|
assert.ok(coupled > uncoupled, `coupled ${coupled} should exceed uncoupled ${uncoupled}`);
|
||||||
|
assert.ok(coupled > 0.3, `dependency-coupled risk ${coupled} should be elevated`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── TCAS advisories ──
|
||||||
|
test('advise: clear when far apart', () => {
|
||||||
|
const a = { agentId: 'a', files: [{ path: 'src/api/users.js' }] };
|
||||||
|
const b = { agentId: 'b', files: [{ path: 'docs/guide.md' }] };
|
||||||
|
assert.strictEqual(advise(a, b, {}).level, 'clear');
|
||||||
|
});
|
||||||
|
test('advise: resolution on same-file, lower-priority agent steers', () => {
|
||||||
|
// a has more committed work (3 weighted files) ⇒ holds; b steers.
|
||||||
|
const a = {
|
||||||
|
agentId: 'lead',
|
||||||
|
files: [
|
||||||
|
{ path: 'src/api/users.js', lines: [[1, 80]], weight: 1 },
|
||||||
|
{ path: 'src/api/posts.js', weight: 1 },
|
||||||
|
{ path: 'src/api/auth.js', weight: 1 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const b = { agentId: 'worker', files: [{ path: 'src/api/users.js', lines: [[1, 80]], weight: 1 }] };
|
||||||
|
const v = advise(a, b, {});
|
||||||
|
assert.strictEqual(v.level, 'resolution', `level was ${v.level} (risk ${v.risk})`);
|
||||||
|
assert.strictEqual(v.transmit, true);
|
||||||
|
assert.strictEqual(v.steer, 'worker', 'lower-priority worker steers');
|
||||||
|
assert.strictEqual(v.hold, 'lead', 'higher-priority lead holds');
|
||||||
|
});
|
||||||
|
test('advise: deterministic — same inputs give same maneuver', () => {
|
||||||
|
const a = { agentId: 'a', files: [{ path: 'x/y.js', lines: [[1, 20]] }] };
|
||||||
|
const b = { agentId: 'b', files: [{ path: 'x/y.js', lines: [[1, 20]] }] };
|
||||||
|
const v1 = advise(a, b, {});
|
||||||
|
const v2 = advise(a, b, {});
|
||||||
|
assert.deepStrictEqual({ s: v1.steer, h: v1.hold, l: v1.level }, { s: v2.steer, h: v2.hold, l: v2.level });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── closure rate ──
|
||||||
|
test('closureRate: positive when approaching', () => {
|
||||||
|
assert.ok(closureRate(0.2, 0.5, 1000) > 0);
|
||||||
|
assert.ok(closureRate(0.6, 0.3, 1000) < 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── embedding ──
|
||||||
|
test('embedAgents: tree-close agents embed closer than far ones', () => {
|
||||||
|
const near1 = { agentId: 'n1', files: [{ path: 'src/api/users.js' }] };
|
||||||
|
const near2 = { agentId: 'n2', files: [{ path: 'src/api/posts.js' }] };
|
||||||
|
const far = { agentId: 'f', files: [{ path: 'docs/guide.md' }] };
|
||||||
|
const { positions } = embedAgents([near1, near2, far], {});
|
||||||
|
const pos = Object.fromEntries(positions.map(p => [p.agentId, p.position]));
|
||||||
|
const dNear = euclid(pos.n1, pos.n2);
|
||||||
|
const dFar = euclid(pos.n1, pos.f);
|
||||||
|
assert.ok(dNear < dFar, `near pair ${dNear} should embed closer than far ${dFar}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── full scan ──
|
||||||
|
test('scanAirspace: surfaces only non-clear advisories, sorted by risk', () => {
|
||||||
|
const agents = [
|
||||||
|
{ agentId: 'a', files: [{ path: 'src/api/users.js', lines: [[1, 50]] }] },
|
||||||
|
{ agentId: 'b', files: [{ path: 'src/api/users.js', lines: [[1, 50]] }] }, // collides with a
|
||||||
|
{ agentId: 'c', files: [{ path: 'docs/guide.md' }] } // clear of everyone
|
||||||
|
];
|
||||||
|
const scan = scanAirspace(agents, {});
|
||||||
|
assert.strictEqual(scan.counts.agents, 3);
|
||||||
|
assert.ok(scan.advisories.length >= 1, 'a/b should produce an advisory');
|
||||||
|
assert.strictEqual(scan.advisories[0].risk, Math.max(...scan.advisories.map(x => x.risk)));
|
||||||
|
// c is clear of both ⇒ not in advisories
|
||||||
|
assert.ok(!scan.advisories.some(adv => adv.a === 'c' || adv.b === 'c'));
|
||||||
|
assert.strictEqual(scan.positions.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||||
|
if (failed > 0) process.exit(1);
|
||||||
Loading…
x
Reference in New Issue
Block a user