diff --git a/docs/design/agent-proximity.md b/docs/design/agent-proximity.md new file mode 100644 index 00000000..649d13a9 --- /dev/null +++ b/docs/design/agent-proximity.md @@ -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. diff --git a/scripts/lib/agent-proximity/distance.js b/scripts/lib/agent-proximity/distance.js new file mode 100644 index 00000000..7118825f --- /dev/null +++ b/scripts/lib/agent-proximity/distance.js @@ -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 } +}; diff --git a/scripts/lib/agent-proximity/graph.js b/scripts/lib/agent-proximity/graph.js new file mode 100644 index 00000000..98bc05a3 --- /dev/null +++ b/scripts/lib/agent-proximity/graph.js @@ -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 +}; diff --git a/scripts/lib/agent-proximity/index.js b/scripts/lib/agent-proximity/index.js new file mode 100644 index 00000000..cd934c27 --- /dev/null +++ b/scripts/lib/agent-proximity/index.js @@ -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 +}; diff --git a/tests/lib/agent-proximity.test.js b/tests/lib/agent-proximity.test.js new file mode 100644 index 00000000..f4b2787f --- /dev/null +++ b/tests/lib/agent-proximity.test.js @@ -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);