Skip to content

context-graph-enrich: two-regime rework (full-session T1, curate clustering, Batch API) - #124

Merged
philcunliffe merged 3 commits into
masterfrom
enrich-two-regime-rework
Jun 18, 2026
Merged

context-graph-enrich: two-regime rework (full-session T1, curate clustering, Batch API)#124
philcunliffe merged 3 commits into
masterfrom
enrich-two-regime-rework

Conversation

@philcunliffe

Copy link
Copy Markdown
Contributor

Implements the LLP 0028 redesign (the spec edit lands with the code, one PR). Closes the enrichment truncation defect and adds the two-regime (backfill + ongoing) pipeline through the Anthropic Batch API.

Phase A — per-session watermark + full-session T1

  • state.js schema v4 sidecar holds a per-session high-water mark, replacing the global (timestamp, tiebreak) keyset cursor.
  • propose.js selects a whole session, stitches its filtered parts in DAG order, and makes one T1 call per session — no more 12k-char cap (which silently lost the tail of ~47% of sessions / ~70% of NL content).
  • Regime selectors: ongoing (settled = latest part older than settle_cutoff_minutes AND past mark, capped by max_sessions_per_tick) vs backfill (all sessions), via a cheap GROUP BY session aggregate.

Phase B — curate clustering + produced-edge-per-session

  • curate.js clusters by recall-region (bucket warm prospects by top recalled node id) + embedding-cosine the no-recall remainder (uses hypaware.embedder, best-effort; falls back to per-session grouping). Shared context is now content-based (union of recalled committed nodes), not the structural one-hop neighborhood.
  • merge now writes a committed row under the canonical key with the merging session's anchor → a produced edge per contributing session; the node collapses by content-addressed id.
  • New curate config: recall_cluster_floor, cluster_similarity, max_cluster_size (dropped expand_depth).

Phase C — Batch API + backfill + ongoing batch regime

  • @hypaware/completion-anthropic gains an optional batch surface (Anthropic Message Batches: submit/poll/results/cancel, raw HTTP + injected fetch; a refusal is a successful result; provider error messages are never surfaced).
  • New hyp enrich backfill command (out of daemon: propose every session → curate the whole pool via the Batch API, polling to completion).
  • The ongoing curate daemon source is now submit-and-collect (job state in the sidecar) so the frontier curator work never blocks a tick.

Verification

  • npm test1251 pass / 0 fail / 1 skipped
  • tsc -p tsconfig.json --noEmit0 errors
  • npm run lint → clean (378 files)
  • All @ref annotations resolve to real LLP 0028 headings.

Notes / follow-ups

  • Backfill propose is synchronous (all sessions sequentially via Haiku); only T2 curate is batched. A follow-up could batch propose too for a faster cold-start over a large corpus.
  • No hermetic enrich smoke exists; a real end-to-end run needs a working ANTHROPIC API key. Coverage here is unit tests (incl. fake-fetch/fake-batch) + typecheck.

🤖 Generated with Claude Code

…tering, Batch API)

Implements the LLP 0028 redesign; the spec edit lands with the code.

- Per-session high-water mark replaces the global (timestamp, tiebreak) cursor
  (state.js schema v4). T1 propose now extracts a whole DAG-ordered session in a
  single call — closing the 12k-char truncation defect (silent loss on 47% of
  sessions). Two regime selectors: ongoing (settled + past-watermark, capped) and
  backfill (all sessions).
- T2 curate clusters by recall-region + embedding-cosine the no-recall remainder
  (hypaware.embedder, best-effort), with content-based shared context replacing
  the structural one-hop neighborhood. merge now writes a committed row under the
  canonical key with the merging session's anchor → a `produced` edge per
  contributing session; the node dedups by content-addressed id.
- @hypaware/completion-anthropic gains an optional `batch` surface (Anthropic
  Message Batches: submit/poll/results/cancel; refusal = success; provider error
  messages never surfaced). New `hyp enrich backfill` command, and the ongoing
  curate daemon source is now submit-and-collect (job state in the sidecar) so
  frontier work never blocks a tick.

npm test 1251 pass / 0 fail; tsc 0 errors; lint clean; all @refs resolve.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Dual-agent review — request_changes

  • Verdict: request_changes
  • Risk class: high
  • Auto-merge advisory: 👎 thumbs down — verdict is request_changes; needs human-gated follow-up

Advisory only: no merge was attempted.

Risk capstone

Cross-reference: reviewer findings vs high-risk surfaces

Source Finding (severity, evidence) Intersects
Codex #4 / Claude propose/curate sidecar lost-update race (major; propose.js:48,106 / batch.js:139,168) Concurrency surface; Risk #1, #2
Codex #1 same-timestamp watermark drops parts (major; propose.js:153-260) Risk #3 (data loss)
Codex #2 merge without item_type mis-routes produced edge (major; contract.js:32 / curate.js:557) Risk #4
Claude parseBackfillArgv untested (minor; commands.js) Risk #5 (coverage)
Claude LLP 0028 "DAG order" ref dishonest (minor; llp/0028:70-71) Targets (LLP), not a runtime surface
Claude inline import('...') types (minor; batch.js:219-220, curate.js:130) Targets (style), not a runtime surface
Codex review

Fix Validations

Enrichment truncation defect

  • Status: correct
  • Evidence: hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:65, hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:78, hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:197, hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:238
  • Assessment: The old slice model is gone: a selected session reads all filtered parts, builds one transcript, and makes one T1 call. I did find separate ordering/watermark issues below, but the 12k input truncation path itself is removed.

Findings

1) Behavioral Correctness

  • Severity: major
  • Confidence: high
  • Evidence: hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:153, hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:155, hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:178, hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:182, hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:260
  • Why it matters: The persisted mark is (ts,id), but the ongoing selector only aggregates MAX(ts) and excludes lastTs <= prev.ts, so a newly arrived same-timestamp part with a higher tiebreak id is never selected and its text is lost.
  • Suggested fix: Make the session selector compare the full latest tuple, not timestamp alone. For example, return the latest row id at the max timestamp per session, compare (last_ts,last_id) to the stored mark, and add a regression where mark {ts:1000,id:'p1'} must reselect a session whose latest part is {ts:1000,id:'p2'}.

4) Concurrency, Ordering & State Safety

  • Severity: major
  • Confidence: high
  • Evidence: hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:48, hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:106, hypaware-core/plugins-workspace/context-graph-enrich/src/batch.js:136, hypaware-core/plugins-workspace/context-graph-enrich/src/batch.js:139, hypaware-core/plugins-workspace/context-graph-enrich/src/index.js:73, hypaware-core/plugins-workspace/context-graph-enrich/src/index.js:80
  • Why it matters: enrich-propose and enrich-curate are independent sources sharing one sidecar; a long propose tick can read curate_job: null, the curate tick can submit and persist a batch job, and then propose can write its stale state.curate_job back to null, orphaning the provider batch and allowing duplicate submits.
  • Suggested fix: Re-read state immediately before the propose write and merge into the latest state: preserve latest.curate_job and merge latest.session_marks with newMarks. Add an interleaving test that writes a curate_job between propose’s initial read and final write.

2) Contract & Interface Fidelity

  • Severity: major
  • Confidence: medium
  • Evidence: hypaware-core/plugins-workspace/context-graph-enrich/src/contract.js:32, hypaware-core/plugins-workspace/context-graph-enrich/src/prompts.js:63, hypaware-core/plugins-workspace/context-graph-enrich/src/prompts.js:71, hypaware-core/plugins-workspace/context-graph-enrich/src/curate.js:557, hypaware-core/plugins-workspace/context-graph-enrich/src/curate.js:562
  • Why it matters: Merge provenance only converges under the same canonical (item_type,item_id), but the tool schema does not require item_type; if the model returns merge_into without item_type, routing falls back to the prospect type and can attach the produced edge to the wrong content-addressed node.
  • Suggested fix: Treat merge without item_type as invalid/pending, or make the schema enforce conditional merge fields and add a routeDecision test for omitted item_type.

No Finding

  1. Change Impact / Blast Radius
  2. Error Handling & Resilience
  3. Security Surface
  4. Resource Lifecycle & Cleanup
  5. Release Safety
  6. Test Evidence Quality
  7. Architectural Consistency
  8. Debuggability & Operability

Evidence Bundle

  • Changed hot paths: per-session propose selector/watermark, shared state sidecar writes, ongoing curate submit/collect, merge-to-committed routing, Anthropic batch client.
  • Impacted callers: hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:392, hypaware-core/plugins-workspace/context-graph-enrich/src/batch.js:291, hypaware-core/plugins-workspace/context-graph-enrich/src/index.js:73, hypaware-core/plugins-workspace/context-graph-enrich/src/index.js:80.
  • Impacted tests: test/plugins/context-graph-enrich-propose.test.js:138, test/plugins/context-graph-enrich-propose.test.js:312, test/plugins/context-graph-enrich-batch.test.js:192, test/plugins/context-graph-enrich-state.test.js:33, test/plugins/context-graph-enrich-curate.test.js:89.
  • Unresolved uncertainty: I did not run tests; review is based on the provided diff plus targeted trace reads. I did not verify current Anthropic API behavior externally.
Claude review

Claude review

Propose tick clobbers a concurrently-submitted curate batch job (lost update → orphaned batch + double spend)

  • Severity: major
  • Confidence: 85
  • Evidence: hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js:48,106
  • Why it matters: runProposeTick reads state once at tick start (:48), then does a long per-session complete() await loop, then writes writeState(..., { schema_version: 4, session_marks: {...}, curate_job: state.curate_job }) (:106) using the START-OF-TICK snapshot of curate_job. The propose and curate daemon sources run on independent timers with no shared lock. If a curate tick's submitCurateJob persists a new curate_job during propose's await window, propose's write resets curate_job back to its stale value (typically null): the submitted batch is never collected (results lost) and the next curate tick submits a duplicate batch over the same pool (frontier-model double spend). This is the mirror of a race the authors explicitly guarded against — submitCurateJob (batch.js:139-142) and collectCurateJob (batch.js:203) both re-read state right before writing precisely to avoid clobbering concurrent mark advances; the propose side was left unguarded. Found independently by two reviewers (shallow-bug and historical-context).
  • Suggested fix: Make propose's final write a read-modify-write that preserves the current curate_job: const cur = readState(runtime.stateDir); writeState(runtime.stateDir, { schema_version: 4, session_marks: { ...cur.session_marks, ...newMarks }, curate_job: cur.curate_job }), or factor a shared updateState(stateDir, fn) helper both paths use.

LLP 0028 claims a "DAG order" the code deliberately does not implement

  • Severity: minor
  • Confidence: 88
  • Evidence: llp/0028-context-graph-enrichment.decision.md:70-71 vs hypaware-core/plugins-workspace/context-graph-enrich/src/propose.js (orderSessionParts)
  • Why it matters: LLP 0028 §two-tiers-one-pipeline says a session's parts "are stitched in DAG order (message_index / previous_message_id / agent_id)". The implementation sorts purely by (timestamp, tiebreak) and its own JSDoc explicitly rejects those columns ("without coupling to ai_gateway_messages-specific columns ... which a custom source may lack"). orderSessionParts carries @ref LLP 0028#two-tiers-one-pipeline [implements], so an [implements] ref points at a design the code intentionally does not realize — violating CLAUDE.md's "Keep refs honest" / "land the doc edit in the same commit as the code".
  • Suggested fix: Edit LLP 0028:70-71 to describe the actual ordering — a deterministic (timestamp, tiebreak) sort chosen over message-graph columns for source-portability.

New backfill argv parser (parseBackfillArgv) ships with no test

  • Severity: minor
  • Confidence: 90
  • Evidence: hypaware-core/plugins-workspace/context-graph-enrich/src/commands.js (parseBackfillArgv); no references to parseBackfillArgv/runEnrichBackfill/--propose-only/--curate-only anywhere under test/
  • Why it matters: CLAUDE.md mandates traditional tests for deterministic argv/config parsing-and-validation. parseBackfillArgv is a pure function with three distinct validation rules (reject unknown --flags, reject unexpected positionals, reject mutually-exclusive --propose-only + --curate-only), none exercised. A regression (dropping the mutual-exclusion check, or a startsWith('--') ordering bug) would ship silently. Every other new pure helper in this PR (cosine, greedyCosineClusters, chunkBySize, clusterByRecallRegion, sessionMark) got a focused test; this is the gap.
  • Suggested fix: Add a test for parseBackfillArgv covering bare argv, each flag alone, both flags (error), an unknown --flag (error), and a stray positional (error).

Inline import('...') type expressions in JSDoc

  • Severity: minor
  • Confidence: 90
  • Evidence: hypaware-core/plugins-workspace/context-graph-enrich/src/batch.js:219-220; hypaware-core/plugins-workspace/context-graph-enrich/src/curate.js:130
  • Why it matters: CLAUDE.md Code Style: "Never use inline import('...') types. Declare type imports at the top of the file with @import JSDoc comments, then reference the bare names." These newly-added annotations (import('.../collectivus-plugin-kernel-types.d.ts').VectorSearchHit[], .CompletionRequest) are exactly the forbidden pattern, and both files already have a top-of-file @import block from that same .d.ts, so the names should just be added there.
  • Suggested fix: Add VectorSearchHit / CompletionRequest to the existing top-of-file @import blocks in batch.js and curate.js and reference them bare.

Reports: .git/dual-review/pr-124

philcunliffe and others added 2 commits June 18, 2026 16:30
…outing)

Fixes the dual-review findings on the two-regime enrichment rework:

- propose: select sessions on the EXACT (ts, tiebreak) tuple, not MAX(ts)
  alone. buildSessionAggregateQuery now ranks parts with ROW_NUMBER() OVER
  (PARTITION BY anchor ORDER BY ts DESC, tiebreak DESC) and keeps rn=1, so a
  same-millisecond part that advanced a settled session past its mark is
  reselected instead of silently dropped (Codex #1). selectSessions compares
  the full tuple via cmpMark; the exact match also avoids re-selecting
  already-enriched sessions every tick.

- state: add updateState() read-modify-write helper (atomic w.r.t. the event
  loop). propose's final write and batch submit/collect now merge into the
  latest on-disk state, so a curate_job submitted during propose's await
  window is preserved rather than clobbered to null — no orphaned batch /
  double spend (Codex #4 / Claude).

- curate: an under-specified merge (missing merge_into or item_type) can't be
  routed to the right content-addressed node, so routeDecision returns it
  pending (no commit, no resolution) and routeClusterDecisions leaves it in
  the queue, rather than attaching the produced edge to the wrong node
  (Codex #2).

- commands: export + test parseBackfillArgv (bare argv, each flag, mutual
  exclusion, unknown flag, stray positional, flag ordering).

- llp 0028: describe the actual deterministic (timestamp, tiebreak) ordering
  chosen over message-graph columns for source portability, so the
  orderSessionParts [implements] ref is honest.

- batch/curate: move VectorSearchHit/CompletionRequest to top-of-file @import
  blocks (no inline import('...') types).

npm test 1264 pass / 1 skipped; tsc --noEmit clean; lint clean (379 files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… flake)

The pre-existing batch tests failed in CI (ubuntu-latest's Node 20) while
passing on local Node 24: `pollUntilEnded` awaits `delay()`, whose setTimeout
was `unref()`'d, so the test-runner event loop could drain before the timer
fired — reported as "Promise resolution is still pending but the event loop
has already resolved", poisoning the whole batch test file.

Removing the unref also fixes a latent bug: this delay is awaited inside the
backfill command's run-to-completion poll loop, so it must keep the loop alive
— an unref'd timer could let `hyp enrich backfill` exit mid-poll. The daemon
source intervals keep their unref (correct: never block shutdown); the abort
signal still clears this timer for prompt cancellation.

npm test 1264 pass / 1 skipped; tsc clean; lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant