diff --git a/docs/control-plane/TCAS-HOOK.md b/docs/control-plane/TCAS-HOOK.md new file mode 100644 index 000000000..9b9b02c58 --- /dev/null +++ b/docs/control-plane/TCAS-HOOK.md @@ -0,0 +1,81 @@ +# TCAS hook: pre-merge deconfliction (slice b, design) + +Status: design only. Nothing in this document is implemented. Slice (a), the live view and the advisory feed it reads, shipped in `VIEW-CONTRACT.md`. + +## Goal + +Stop two agents from finishing overlapping edits and meeting at the merge. The scan already knows when two working sets converge; the hook is what turns that knowledge into a maneuver inside the harness, before either agent commits. + +Push plan wording: "a PreToolUse/Edit hook that reads the advisory feed and returns steer, pause or wait for the lower-priority agent, logged to the capsule." + +## Inputs + +1. The event feed: `GET /api/control-plane/events` on the local control pane, or the same document written to a file by `scripts/proximity-tick.js --json` for sessions without a pane. Events of kind `proximity.advisory` with `action.type` `transmit` or `steer` and a deterministic `id`. +2. The hook's own session id. Claude Code passes `session_id` on stdin; the ECC session adapter maps it to the ECC2 `sessions.id` the scan uses. Codex and Hermes use the instruction-backed equivalent (see below). +3. The tool call: `tool_name` and `tool_input.file_path` for Edit, Write and MultiEdit. Bash is out of scope for v1. + +## Decision + +For each advisory event whose `subject` includes this session: + +| Event | This session is | Maneuver | Hook result | +|---|---|---|---| +| `traffic`, action `transmit` | either side | **transmit**: inject the other agent's working set as a system message | exit 0, message on stderr (warn, never block) | +| `resolution`, action `steer` | `hold` | **hold**: continue | exit 0, short note | +| `resolution`, action `steer` | `steer`, and `file_path` is in the other agent's working set | **pause**: stop editing that file until the other agent's diff lands | exit 2 with the reason (blocks this one tool call) | +| `resolution`, action `steer` | `steer`, and `file_path` is not in the other agent's working set | **wait**: allowed, but told to keep to non-overlapping files | exit 0, message on stderr | +| `resolution`, action `steer` | `steer`, and a `steer` target exists | **steer**: suggest the disjoint files or subtree the agent should move to | exit 0, message; exit 2 only if the edit is on the shared file | + +The maneuver is deterministic: both agents read the same event, `hold` and `steer` are named in it, so the two sides never pick the same move. This is the TCAS coordination property and it is why the view computes right-of-way once, centrally, rather than each hook deciding. + +`pause` blocks a single tool call, not the session. The agent sees the reason and can pick another file. Blocking is bounded by the event's `at`: an event older than the pane's poll interval times three is stale and the hook does not block on it. + +## Priority + +Right-of-way comes from the event (`action.hold`, `action.steer`). The view computes it as more progress, then earlier start, then stable id (`rightOfWay` in `scripts/lib/agent-proximity/distance.js`). The hook never recomputes it. + +## Logging to the capsule + +Every decision is one entry in the session's capsule journal (`scripts/lib/eval-harness/capsule.js`, hash-linked NDJSON): + +```json +{ + "kind": "tcas.decision", + "event_id": "proximity.advisory:session-a|session-b:resolution", + "session": "session-b", + "tool": "Edit", + "file": "src/api/users.js", + "maneuver": "pause", + "blocked": true, + "risk": 1, + "threshold": { "ta": 0.35, "ra": 0.7, "source": "static" }, + "at": "2026-09-11T20:01:03.000Z" +} +``` + +The capsule is the baseline counter for the 85 percent goal: rebase and merge-conflict triage incidents per week are counted from these entries plus `git rerere` and conflict markers, two weeks before and two weeks after the hook is on. No percentage is claimed before that. + +## Where it plugs in + +- **Claude Code**: a `PreToolUse` entry in `hooks/hooks.json` with matcher `Edit|Write|MultiEdit`, routed through `scripts/hooks/run-with-flags.js` so `ECC_HOOK_PROFILE` and `ECC_DISABLED_HOOKS` gate it. Script under `scripts/hooks/tcas-pre-edit.js`, helpers in `scripts/lib/control-pane/tcas.js`. Budget: under 200 ms, no network beyond loopback, exit 0 on any parse or fetch error. +- **Codex**: no PreToolUse. The instruction-backed equivalent is the `proximity_steer` / `proximity_hold` message the tick already writes into the ECC2 `messages` table, surfaced on the next turn. `pause` degrades to a strong instruction. +- **Hermes**: gateway hook on the tool-call path, same decision table, same capsule entry. + +## Off switch and safety + +- Disabled by default. On with `ECC_TCAS_HOOK=1` or the hook profile. +- Read-only against the pane. It never writes to the sessions or messages tables. +- No lease is acquired. Durable leases are slice (c), the worktree lease table in ecc2 `session/store.rs` next to `messages`; until then a `pause` is a per-call block, not a lock, and two hooks racing on the same file is possible but harmless (both see the same event and the same `steer`). +- Fails open. Any error is exit 0 with a `[TCAS]` line on stderr. + +## Tests to write with it + +- Decision table: one test per row above, driven by a fixture event feed and a stdin payload. +- Staleness: an event older than the window does not block. +- Fail-open: unreachable pane, malformed JSON, missing session id. +- Capsule: one entry per decision, hash chain intact, replay reproduces the same bytes. +- Integration: two fake sessions with overlapping working sets, the lower-priority one gets exit 2 on the shared file and exit 0 on a disjoint file. + +## Out of scope for (b) + +Learned thresholds, closure-rate escalation, mesh mode, cross-machine airspace, the `x_sem`, `x_vec`, `x_freq` channels (slice g), and the lease table (slice c). diff --git a/docs/control-plane/VIEW-CONTRACT.md b/docs/control-plane/VIEW-CONTRACT.md new file mode 100644 index 000000000..8f6f00abb --- /dev/null +++ b/docs/control-plane/VIEW-CONTRACT.md @@ -0,0 +1,141 @@ +# ECC control-plane live view: `ecc.control-plane.view.v1` + +Status: shipped with the control pane (`scripts/lib/control-pane/control-plane-view.js`). Read-only. Advisory only. + +The view joins three things the repo already computes separately and serves them as one JSON document shaped as tasks, lanes and events, so another control plane (the Ito ops board, a Hermes or Codex reader, a hook) can consume it without knowing ECC internals. + +| Input | Where it comes from | +|---|---| +| Sessions | `scripts/lib/control-pane/state.js`, the ECC2 `sessions` table | +| Pairwise proximity | `scripts/lib/agent-proximity/` (noisy-OR over `x_tree`, `x_overlap`, `x_dep`) via `scripts/lib/control-pane/proximity.js` | +| 2D projection | `scripts/lib/agent-proximity/projection.js` (rolling z-score, tails clipped at 2.5 / 97.5, PCA) | +| Coordination inventory | `scripts/lib/coordination-inventory.js` (PR #3028): declared tasks and sessions, heartbeat freshness, lease conflicts | + +## Endpoints + +Served by `node scripts/control-pane.js` (loopback only, same Host and Origin gate as the rest of the pane): + +| Route | Returns | +|---|---| +| `GET /control-plane` | Self-contained HTML page: 2D projection canvas, lanes and tasks, event feed. No external scripts. | +| `GET /api/control-plane` | The full view document below. | +| `GET /api/control-plane/events` | `{ schemaVersion, generatedAt, thresholds, events, counts }` only, for hooks and pollers. | + +The server keeps one projection window per process. Both API routes share a snapshot cached for five seconds, and concurrent refresh requests are coalesced. Reads within that interval do not add samples. After expiry, the next read refreshes the snapshot once; idle intervals do not generate synthetic samples. Failed refreshes return errors rather than healthy empty data. The page rejects failed HTTP responses and invalid view envelopes and shows `offline`. Options on `createControlPaneServer`: `projection` (`windowSize`, `clipPercentiles`), `viewOptions` (`thresholds`, `manifest`, `channelWeights`, `minWindowForZscore`), `proximityOptions` (passed to the scan). + +## Document + +```json +{ + "schemaVersion": "ecc.control-plane.view.v1", + "generatedAt": "2026-09-11T20:01:00.000Z", + "source": { "snapshotSchema": "ecc.control-pane.snapshot.v1", "repoRoot": "...", "dbPath": "..." }, + "thresholds": { "ta": 0.35, "ra": 0.7, "source": "static" }, + "lanes": [ { "id": "harness:codex", "label": "codex", "kind": "harness", "taskIds": ["session-a"] } ], + "tasks": [ { "...": "see Task" } ], + "pairs": [ { "...": "see Pair" } ], + "events": [ { "...": "see Event" } ], + "projection": { "...": "see Projection" }, + "inventory": { "...": "see Inventory" }, + "counts": { "lanes": 1, "tasks": 1, "agents": 1, "pairs": 0, "events": 0, "advisories": 0, "resolutions": 0 }, + "limits": [ "..." ] +} +``` + +### Task + +One task per session. A session with no changed files is still a task; it has no projection point and no pairs. + +| Field | Meaning | +|---|---| +| `id` | Session id, unchanged. | +| `lane` | Lane id this task belongs to. | +| `label` | Session task text, or the id. | +| `harness`, `agentType`, `state`, `pid` | From the session row. | +| `worktree` | `{ path, branch, base }` or `null`. | +| `heartbeatAt`, `updatedAt` | ISO timestamps or `null`. | +| `workingSet` | `{ fileCount, files }`: the worktree diff against its base. | +| `projection` | `{ point, pairs, maxRisk }` where `point` is `[x, y]` or `null`. `point` is the risk-weighted centroid of the task's pair points in PCA space. | +| `inventory` | `{ id, heartbeat, process, authority: "declared-only" }`. `id` is the sanitized identifier used in the inventory manifest; `heartbeat` and `process` are the #3028 observations. | + +### Lane + +A grouping of tasks. Precedence: `task-group` (session `task_group`), then `project`, then `harness`. Ids are prefixed (`group:`, `project:`, `harness:`) so a consumer can tell the kinds apart without reading `kind`. + +### Pair + +One row per agent pair from the airspace scan (only sessions with edits participate). + +| Field | Meaning | +|---|---| +| `a`, `b` | Session ids. | +| `risk`, `level` | Noisy-OR risk and the scan's level (`clear`, `advisory`, `resolution`) at the scan's thresholds. | +| `channels` | Raw `{ x_tree, x_overlap, x_dep }` in [0, 1]. | +| `normalized` | The same after z-score, clip and map-back, or equal to `channels` while the window is cold. | +| `point` | `[pc1, pc2]` PCA scores. | + +### Event + +Something an operator or a hook may act on. Ids are deterministic across polls so a consumer can dedupe. + +```json +{ + "id": "proximity.advisory:session-a|session-b:resolution", + "kind": "proximity.advisory", + "level": "resolution", + "severity": "critical", + "at": "2026-09-11T20:01:00.000Z", + "subject": { "a": "session-a", "b": "session-b", "aLabel": "...", "bLabel": "..." }, + "risk": 1, + "distance": 0, + "channels": { "x_tree": 1, "x_overlap": 1, "x_dep": 0 }, + "threshold": { "ta": 0.35, "ra": 0.7, "crossed": "ra", "source": "static" }, + "action": { "type": "steer", "steer": "session-b", "hold": "session-a" }, + "message": "Resolution advisory: session-b steers, session-a holds (risk 100%, static threshold 0.7)." +} +``` + +| Kind | Levels | Action types | Source | +|---|---|---|---| +| `proximity.advisory` | `traffic` (risk at or above `ta`), `resolution` (at or above `ra`) | `transmit` (both agents share intent), `steer` (`steer` moves, `hold` keeps course) | Every pair link, evaluated against the view's thresholds. Right-of-way: more progress, then earlier start, then stable id. | +| `inventory.lease-conflict` | `conflict` | `review` | #3028 `leaseConflicts`. Declared-only, never a lock. | + +Thresholds are static per view (`source: "static"`). A learned threshold, closure-rate escalation, and the `pause` and `wait` maneuvers are slice (b), see `TCAS-HOOK.md`. + +### Projection + +```json +{ + "method": "pca", + "channels": ["x_tree", "x_overlap", "x_dep"], + "weights": { "x_tree": 0.25, "x_overlap": 1, "x_dep": 0.9 }, + "normalization": "zscore-clipped", + "window": { "samples": 12, "percentiles": [2.5, 97.5], "channels": [ { "channel": "x_tree", "mean": 0.39, "stddev": 0.42, "clipLow": -0.92, "clipHigh": 1.45 } ] }, + "pca": { "loadings": [ { "x_tree": 0.12, "x_overlap": 0.87, "x_dep": -0.47 }, { "...": "..." } ], "explainedVariance": [0.6, 0.39] }, + "agents": [ { "agentId": "session-a", "point": [0.18, 0.41], "pairs": 3, "maxRisk": 1 } ] +} +``` + +Pipeline per poll: every pair's channel vector is pushed into a rolling window (default 512 samples). Once the window holds at least 8 samples, each channel is z-scored against the window, clipped to the window's 2.5th and 97.5th percentile (in z units), mapped back to [0, 1], multiplied by the static channel weight, and the weighted matrix goes through PCA (Jacobi on the 3x3 covariance). Below 8 samples the raw channel values are used and `normalization` says `raw`. A channel with zero variance maps to 0.5. Degenerate inputs (fewer than two pairs, zero total variance) give zero scores, never NaN. + +The projection is a display. It never changes `risk`, the advisory level, or right-of-way. + +### Inventory + +The #3028 report with the per-task rows folded into `tasks[].inventory`. Kept at the top level: `status` (`ok` or `unavailable` with `reason`), `truncated` (more than 64 sessions), `observedAt`, `mode: "read-only"`, `activity`, `leaseConflicts`, `warnings`, `coverage`, `limits`. The manifest is built from the live sessions (ids sanitized to the inventory alphabet, paths from the working set, heartbeat from the session row, declared session status `open` for running/pending/idle, `closed` for completed/failed/stopped). An external manifest (`viewOptions.manifest`) can add `goals`, `leases`, `repositories` and extra `tasks`; the inventory then reports lease conflicts and goal activity for them. + +## Reuse in the Ito ops control plane + +The shape to copy is `task`, `lane`, `event`: + +- a **task** has an `id`, a `lane`, a `state`, an optional position, and an observation block whose `authority` says how much to trust it; +- a **lane** is a named group with ordered `taskIds`; +- an **event** has a stable `id`, a `kind`, a `level`, a `severity`, an `at`, a `subject`, an `action` with a `type`, and a human `message`. + +Nothing in the shape is ECC-specific except the event kinds. An ops board that renders lanes of tasks and a feed of events can render this document as-is, and can emit its own kinds (`deal.stalled`, `bridge.down`) into the same feed. + +## What this does not do + +- No leases are acquired, no agent is paused or steered. Consumers act; the view reports. +- No conflict-reduction percentage is claimed. The 85 percent goal in the push plan is measured two weeks before and after slice (b), not here. +- No semantic, call-graph or frequency channel yet (slice (g)). PCA picks new channels up automatically when they land in the scan. diff --git a/scripts/lib/agent-proximity/distance.js b/scripts/lib/agent-proximity/distance.js index 2cddcbb89..8042d3e5e 100644 --- a/scripts/lib/agent-proximity/distance.js +++ b/scripts/lib/agent-proximity/distance.js @@ -270,6 +270,21 @@ function agentPriority(agent) { return { progress, ageMs: startedAt ? Date.now() - startedAt : 0 }; } +/** + * Right-of-way between two agents: more progress wins; tie goes to the earlier + * start (greater age); final deterministic tiebreak on agentId so the maneuver + * is coordinated. Returns { hold, steer } as agentIds. + */ +function rightOfWay(a, b) { + const pa = agentPriority(a); + const pb = agentPriority(b); + 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); + return { hold: aHasPriority ? a.agentId : b.agentId, steer: aHasPriority ? b.agentId : a.agentId }; +} + /** * TCAS-style advisory between two agents given their collision risk. * Returns { level: 'clear'|'advisory'|'resolution', risk, transmit, steer, hold }. @@ -284,17 +299,7 @@ function advise(a, b, graph = {}, options = {}) { 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; + const { hold, steer } = rightOfWay(a, b); if (risk < thresholds.ra) { // Traffic advisory: exchange intent, no one has to move yet. @@ -324,6 +329,7 @@ module.exports = { treeRisk, collisionRisk, agentPriority, + rightOfWay, advise, closureRate, _internal: { normalizePath, segments, jaccard } diff --git a/scripts/lib/agent-proximity/index.js b/scripts/lib/agent-proximity/index.js index 6815fe291..429c2e17d 100644 --- a/scripts/lib/agent-proximity/index.js +++ b/scripts/lib/agent-proximity/index.js @@ -135,7 +135,8 @@ function scanAirspace(agents, graph = {}, options = {}) { b: b.agentId, risk: verdict.risk, distance: verdict.distance, - level: verdict.level + level: verdict.level, + channels: verdict.channels }); if (verdict.level !== 'clear') { advisories.push({ a: a.agentId, b: b.agentId, ...verdict }); diff --git a/scripts/lib/agent-proximity/projection.js b/scripts/lib/agent-proximity/projection.js new file mode 100644 index 000000000..08a62a685 --- /dev/null +++ b/scripts/lib/agent-proximity/projection.js @@ -0,0 +1,305 @@ +'use strict'; + +/** + * 2D projection of the pairwise proximity channels for the control-plane view. + * + * Input: one row per agent pair, the shipped channel vector + * x = [x_tree, x_overlap, x_dep] each in [0, 1] + * (distance.js: treeRisk, overlapRisk, dependencyRisk). + * + * Pipeline (COMPETITION-AND-VISION section 4, "Normalization and projection"): + * 1. z-score each channel against a rolling window of pair samples, + * 2. clip the tails at the 2.5th and 97.5th percentile of that window, + * 3. map back to [0, 1], + * 4. apply the static channel weights (same omega as the noisy-OR), + * 5. PCA over the weighted matrix, keep the first two components. + * + * Agent positions are the risk-weighted centroid of the projected points of + * the pairs the agent belongs to. Nothing here changes the risk or the + * advisory: the projection is a display, not a decision. + * + * No runtime dependencies. The eigen-decomposition is a Jacobi sweep over the + * 3x3 covariance matrix, which is exact enough for a display. + */ + +const CHANNEL_ORDER = ['tree', 'overlap', 'dependency']; +const CHANNEL_LABELS = { tree: 'x_tree', overlap: 'x_overlap', dependency: 'x_dep' }; + +const PROJECTION_DEFAULTS = { + windowSize: 512, + minWindowForZscore: 8, + clipPercentiles: [2.5, 97.5], + components: 2 +}; + +function finite(x) { + return Number.isFinite(x) ? x : 0; +} + +function mean(values) { + if (values.length === 0) return 0; + let s = 0; + for (const v of values) s += v; + return s / values.length; +} + +function stddev(values, mu) { + if (values.length < 2) return 0; + let s = 0; + for (const v of values) s += (v - mu) * (v - mu); + return Math.sqrt(s / (values.length - 1)); +} + +/** + * Linear-interpolated percentile (p in [0, 100]) of a numeric array. + */ +function percentile(values, p) { + const sorted = values.filter(Number.isFinite).slice().sort((a, b) => a - b); + if (sorted.length === 0) return 0; + if (sorted.length === 1) return sorted[0]; + const rank = (Math.min(100, Math.max(0, p)) / 100) * (sorted.length - 1); + const lo = Math.floor(rank); + const hi = Math.ceil(rank); + if (lo === hi) return sorted[lo]; + return sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo); +} + +/** + * Rolling window of pair channel samples. Each push records one sample vector; + * the window keeps the newest `size` samples. `stats()` returns, per channel, + * the mean, standard deviation and clip bounds (in z units) used to normalize. + */ +function createProjectionWindow(options = {}) { + const size = Number.isFinite(options.windowSize) && options.windowSize > 0 ? Math.floor(options.windowSize) : PROJECTION_DEFAULTS.windowSize; + const [pLo, pHi] = Array.isArray(options.clipPercentiles) && options.clipPercentiles.length === 2 ? options.clipPercentiles : PROJECTION_DEFAULTS.clipPercentiles; + const samples = []; + + return { + size, + push(vector) { + const row = CHANNEL_ORDER.map((_, i) => finite(vector[i])); + samples.push(row); + if (samples.length > size) samples.splice(0, samples.length - size); + return samples.length; + }, + get length() { + return samples.length; + }, + stats() { + const per = CHANNEL_ORDER.map((channel, i) => { + const column = samples.map(row => row[i]); + const mu = mean(column); + const sigma = stddev(column, mu); + const z = sigma > 0 ? column.map(v => (v - mu) / sigma) : column.map(() => 0); + return { + channel, + mean: mu, + stddev: sigma, + clipLow: percentile(z, pLo), + clipHigh: percentile(z, pHi) + }; + }); + return { samples: samples.length, percentiles: [pLo, pHi], channels: per }; + }, + reset() { + samples.length = 0; + } + }; +} + +/** + * z-score one sample against the window stats, clip to the percentile bounds, + * map back to [0, 1]. A channel with zero variance maps to 0.5. + */ +function normalizeSample(vector, stats) { + return CHANNEL_ORDER.map((_, i) => { + const s = stats.channels[i]; + const v = finite(vector[i]); + if (!(s.stddev > 0)) return 0.5; + const z = (v - s.mean) / s.stddev; + const lo = s.clipLow; + const hi = s.clipHigh; + if (!(hi > lo)) return 0.5; + const clipped = Math.min(hi, Math.max(lo, z)); + return (clipped - lo) / (hi - lo); + }); +} + +/** + * Jacobi eigen-decomposition of a small symmetric matrix. Returns eigenvalues + * (descending) and the matching unit eigenvectors (as columns). + */ +function symmetricEigen(matrix) { + const n = matrix.length; + const a = matrix.map(row => row.slice()); + const v = Array.from({ length: n }, (_, i) => Array.from({ length: n }, (_, j) => (i === j ? 1 : 0))); + for (let sweep = 0; sweep < 64; sweep += 1) { + let off = 0; + for (let p = 0; p < n; p += 1) for (let q = p + 1; q < n; q += 1) off += a[p][q] * a[p][q]; + if (off < 1e-18) break; + for (let p = 0; p < n; p += 1) { + for (let q = p + 1; q < n; q += 1) { + if (Math.abs(a[p][q]) < 1e-14) continue; + const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]); + const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1)); + const c = 1 / Math.sqrt(t * t + 1); + const s = t * c; + for (let k = 0; k < n; k += 1) { + const akp = a[k][p]; + const akq = a[k][q]; + a[k][p] = c * akp - s * akq; + a[k][q] = s * akp + c * akq; + } + for (let k = 0; k < n; k += 1) { + const apk = a[p][k]; + const aqk = a[q][k]; + a[p][k] = c * apk - s * aqk; + a[q][k] = s * apk + c * aqk; + } + for (let k = 0; k < n; k += 1) { + const vkp = v[k][p]; + const vkq = v[k][q]; + v[k][p] = c * vkp - s * vkq; + v[k][q] = s * vkp + c * vkq; + } + } + } + } + const order = Array.from({ length: n }, (_, i) => i).sort((i, j) => a[j][j] - a[i][i]); + return { + values: order.map(i => a[i][i]), + vectors: order.map(i => v.map(row => row[i])) + }; +} + +/** + * PCA over a row matrix. Returns the scores for the first `components` + * components, the loadings (unit eigenvectors) and the explained variance. + * Fewer than two rows, or zero total variance, yields all-zero scores. + */ +function pca(rows, components = PROJECTION_DEFAULTS.components) { + const n = rows.length; + const dims = n > 0 ? rows[0].length : CHANNEL_ORDER.length; + const k = Math.max(1, Math.min(components, dims)); + const centre = Array.from({ length: dims }, (_, d) => mean(rows.map(r => r[d]))); + const zeroScores = rows.map(() => new Array(k).fill(0)); + if (n < 2) { + return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre }; + } + const cov = Array.from({ length: dims }, () => new Array(dims).fill(0)); + for (const row of rows) { + for (let i = 0; i < dims; i += 1) { + for (let j = i; j < dims; j += 1) { + cov[i][j] += (row[i] - centre[i]) * (row[j] - centre[j]); + } + } + } + for (let i = 0; i < dims; i += 1) for (let j = i; j < dims; j += 1) { + cov[i][j] /= n - 1; + cov[j][i] = cov[i][j]; + } + const total = cov.reduce((s, row, i) => s + row[i], 0); + if (!(total > 1e-12)) { + return { scores: zeroScores, loadings: [], explainedVariance: new Array(k).fill(0), centre }; + } + const eig = symmetricEigen(cov); + const loadings = eig.vectors.slice(0, k); + const scores = rows.map(row => loadings.map(vec => vec.reduce((s, w, d) => s + w * (row[d] - centre[d]), 0))); + const explainedVariance = eig.values.slice(0, k).map(val => Math.max(0, val) / total); + return { scores, loadings, explainedVariance, centre }; +} + +function channelVector(channels) { + return CHANNEL_ORDER.map(key => finite(channels && channels[key])); +} + +/** + * Project a set of pair links ({ a, b, risk, channels }) to 2D. + * + * The window is optional; when given, each link's channel vector is pushed + * into it and the normalization uses the window stats (rolling z-score plus + * tail clip). Without a window, or while the window holds fewer than + * `minWindowForZscore` samples, the raw [0, 1] channel values are used and the + * result says so (`normalization: 'raw'`). + * + * @returns {{ pairs, agents, normalization, window, pca }} + */ +function projectPairs(links, options = {}) { + const list = Array.isArray(links) ? links.filter(l => l && l.a !== undefined && l.b !== undefined) : []; + const weights = { tree: 0.25, overlap: 1.0, dependency: 0.9, ...(options.channelWeights || {}) }; + const window = options.window || null; + const minWindow = Number.isFinite(options.minWindowForZscore) ? options.minWindowForZscore : PROJECTION_DEFAULTS.minWindowForZscore; + + const raw = list.map(l => channelVector(l.channels)); + if (window && options.sample !== false) for (const vec of raw) window.push(vec); + + let stats = null; + let normalization = 'raw'; + let normalized = raw; + if (window && window.length >= minWindow) { + stats = window.stats(); + normalized = raw.map(vec => normalizeSample(vec, stats)); + normalization = 'zscore-clipped'; + } + const weighted = normalized.map(vec => vec.map((v, i) => v * finite(weights[CHANNEL_ORDER[i]]))); + const result = pca(weighted, options.components || PROJECTION_DEFAULTS.components); + + const pairs = list.map((l, i) => ({ + a: l.a, + b: l.b, + risk: finite(l.risk), + level: l.level || null, + channels: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], raw[i][d]])), + normalized: Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], normalized[i][d]])), + point: result.scores[i] + })); + + // Agent position: risk-weighted centroid of its pair points. A floor keeps + // a clear pair from vanishing, so every agent with a pair gets a position. + const byAgent = new Map(); + for (const pair of pairs) { + const w = 0.05 + pair.risk; + for (const id of [pair.a, pair.b]) { + const acc = byAgent.get(id) || { sum: pair.point.map(() => 0), w: 0, pairs: 0, maxRisk: 0 }; + pair.point.forEach((x, d) => { + acc.sum[d] += x * w; + }); + acc.w += w; + acc.pairs += 1; + acc.maxRisk = Math.max(acc.maxRisk, pair.risk); + byAgent.set(id, acc); + } + } + const agents = [...byAgent.entries()].map(([agentId, acc]) => ({ + agentId, + point: acc.sum.map(x => (acc.w > 0 ? x / acc.w : 0)), + pairs: acc.pairs, + maxRisk: acc.maxRisk + })); + + return { + method: 'pca', + channels: CHANNEL_ORDER.map(key => CHANNEL_LABELS[key]), + weights: Object.fromEntries(CHANNEL_ORDER.map(key => [CHANNEL_LABELS[key], finite(weights[key])])), + normalization, + window: stats ? { samples: stats.samples, percentiles: stats.percentiles, channels: stats.channels.map(c => ({ ...c, channel: CHANNEL_LABELS[c.channel] })) } : { samples: window ? window.length : 0, percentiles: PROJECTION_DEFAULTS.clipPercentiles, channels: [] }, + pca: { + loadings: result.loadings.map(vec => Object.fromEntries(CHANNEL_ORDER.map((key, d) => [CHANNEL_LABELS[key], vec[d]]))), + explainedVariance: result.explainedVariance + }, + pairs, + agents + }; +} + +module.exports = { + PROJECTION_DEFAULTS, + CHANNEL_ORDER, + CHANNEL_LABELS, + percentile, + createProjectionWindow, + normalizeSample, + pca, + projectPairs, + _internal: { symmetricEigen, mean, stddev } +}; diff --git a/scripts/lib/control-pane/control-plane-view-ui.js b/scripts/lib/control-pane/control-plane-view-ui.js new file mode 100644 index 000000000..2fe9e95cd --- /dev/null +++ b/scripts/lib/control-pane/control-plane-view-ui.js @@ -0,0 +1,243 @@ +'use strict'; + +/** + * Self-contained control-plane live view page, served at /control-plane. + * + * Draws the 2D PCA projection of the agent pairs (projection.js) on a canvas, + * the lanes and tasks beside it, and the advisory event feed. Polls + * /api/control-plane. No external scripts, no framework: it has to work on a + * loopback server with a strict CSP and offline. + */ + +function renderControlPlaneViewHtml() { + return ` + +
+ + +