Compare commits

..
Author SHA1 Message Date
profit ab3b857c7f Merge remote-tracking branch 'origin/main' into test/enrich-prd-pipeline
# Conflicts:
#	auditor/audit.ts
#	auditor/checks/inference.ts
#	auditor/checks/kb_query.ts
#	auditor/claim_parser.ts
#	tests/real-world/scrum_master_pipeline.ts
2026-04-23 00:29:22 -05:00
profit 56dbfb7d03 fact_extractor: project context + fixed verifier-verdict parser
lakehouse/auditor 8 warnings — see review
Two bundled changes. Both came out of J's observation that the
verifier was defaulting to UNVERIFIABLE on domain-specific facts
because it had no idea what Lakehouse was, which project's code it
was reading, or what framework the types belonged to.

1. Project context preamble. Added docs/AUDITOR_CONTEXT.md — a <400-
   word concise description of the project (crates, services,
   architecture phases, the auditor's role itself). fact_extractor
   reads it once, caches it, prepends it to the extract prompt as a
   "PROJECT CONTEXT (for grounding; do NOT extract from this)"
   section. Both extractor and verifier now see this context, so
   statements like "aggregate<T> returns Map<string, AggregateRow>"
   get grounded as "this is a TypeScript function in the Lakehouse
   auditor subsystem" and the verifier can reason about plausibility
   instead of guessing.

2. Verifier-verdict parser fix. Gemma2's output format varies between
   "**Verdict:** CORRECT" and just "* **CORRECT**" inline (observed
   variance across runs). The old regex required "Verdict:" as a
   label and missed the second format — causing all verdicts to
   stay UNCHECKED. Replaced with a two-pass approach: find each
   fact section start ("**N.**" or "N."), slice to the next section,
   scan the slice for the first CORRECT|INCORRECT|UNVERIFIABLE
   token. Handles both formats plus unfenced fallback.

Verified: 4-fact test extraction went from 0/4 verdicts scored
(pre-fix) to 2/4 CORRECT + 2/4 UNVERIFIABLE (post-fix). The 2
UNVERIFIABLE cases are domain-specific code behavior the verifier
legitimately can't confirm without reading source — correct stance,
not a parser miss.

No new consensus modes yet. J suggested adding codereview or
validator as a second pass; holding until we see whether context
injection alone gives sufficient signal lift.
2026-04-23 00:26:01 -05:00
profit 2a97fd7237 claim_parser: skip quoted patterns + tighten PR regex
lakehouse/auditor 7 warnings — see review
Two fixes observed in test sweep on b25e368:

1. The "Phase 45 shipped" quoted test example in a commit message
   body was triggering STRONG_PATTERNS despite being inside quotes —
   produced a block finding that flipped 1/0/1 across 3 back-to-back
   audits. Same bug class as auditor/checks/static.ts (fixed earlier):
   rubric files quote pattern examples, parser can't distinguish.

   Fix: firstUnquotedMatch() wraps firstMatch(); uses isInsideQuotedString()
   to check whether the regex's match position falls inside double /
   single / backtick quotes on the line. Mirrors static.ts exactly.

2. A regex misfire: `(?:PR|commit|prior|...)` in history/proof
   patterns was matching "verified ... in production" because `PR`
   (2 chars) matched the first 2 chars of "production" before the
   `\s*#?\w*` tail absorbed the rest. Tightened to require a digit
   after PR (`PR\s*#?\d+`) and commit to require a hex hash.

Verified: 3 back-to-back audit_one runs before this fix showed the
Phase 45 block flipping 1/0/1; after these fixes, unit tests confirm
quoted examples skip correctly AND real claims ("Phase 45 shipped",
"verified end-to-end against production", "Verified end-to-end on
PR #8") still classify correctly.
2026-04-23 00:18:58 -05:00
profit b25e36881c claim_parser: history/proof claims join empirical class
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "now classify as empirical; fresh claims like "Phase 45 shipped" stay"
PR #9's 4 block findings were all from commit message references to
prior work ("on PR #8", "the proven X", "flipping across N runs").
The cloud reviewer correctly said "the current diff does not prove
that", but the claim was never about the current diff — the proof
lives in the referenced prior PR or test run.

Extended EMPIRICAL_PATTERNS to cover two shared classes:

  1. Runtime metrics (existing) — "58 cloud calls", "306s elapsed"
  2. History/proof refs (new) — "verified on PR #8", "was flipping
     across 9 runs", "the proven escalation ladder", "previously
     observed in PR #6", "tested against commit abc1234"

Both skip diff-verification for the same reason: the proof is outside
the diff. Folded into the existing bucket rather than adding a new
strength tier — the skip discipline is identical so there's no value
in splitting them.

Unit-tested on PR #9's actual failing lines: all 5 historical claims
now classify as empirical; fresh claims like "Phase 45 shipped" stay
strong; pure implementation descriptions ("implements deterministic
classification") still don't match (expected — they're not
claims, they're restatements).
2026-04-22 23:53:07 -05:00
profit a264bcf3fc auditor/kb_stats.ts — on-demand observability without Grafana
lakehouse/auditor 4 blocking issues: cloud: claim not backed — "Primary reviewer (gpt-oss:120b) runs N=3 times in parallel, majority-vote per claim. Tie-break
Reads every KB scratchpad file and prints a dashboard of audit
health: verdict distribution, per-PR verdict instability rate,
consensus discrepancy counters, KB size + distinct-signature growth,
verifier verdict histogram, top recurring entities by cross-PR count.

Also supports --json for feeding CI gates or later piping into a
static dashboard page. --top N caps the entities section.

Current state from running it: 30 audits across 8 PRs, 25% verdict
instability rate (all pre-N=3-consensus), 0 discrepancies logged yet
(audits before commit A didn't persist them), 84 audit_lessons rows
with 28 distinct signatures, 4 audit_facts rows with 20 distinct
entities. No cross-PR recurrences yet — but the machinery prints
them as soon as audits on other PRs produce overlapping entities.

This is the full observability surface for PR #9 — the Grafana
alternative I proposed in the counter-plan. Zero infra, 280 LOC,
zero maintenance. If someone later wants a real dashboard, `--json`
output pipes directly into any visualization layer.
2026-04-22 23:41:50 -05:00
profit 181c35b829 scrum_master fact extraction + verifier gate + schema_version bump
Three bundled changes that round out the KB enrichment pipeline
(PR #9 commits B/C/D compressed into one — they all touch the same
persist surfaces so splitting them would just add noise):

B. scrum_master reviews now route accepted review bodies through
   fact_extractor (same llm_team extract pipeline as inference) and
   append to data/_kb/audit_facts.jsonl tagged source:"scrum_review".
   One KB, two producers — downstream consumers can filter by source
   when they care about provenance. Skips reviews <120 chars
   (one-liners / LGTM-type comments with no extractable knowledge).

C. Verifier-gated fact persistence. fact_extractor now parses the
   verifier's free-form prose into per-fact verdicts (CORRECT /
   INCORRECT / UNVERIFIABLE / UNCHECKED). Facts marked INCORRECT are
   dropped on write; CORRECT + UNVERIFIABLE + UNCHECKED are kept
   (dropping UNVERIFIABLE would lose ~90% of real signal — the
   verifier's prior-knowledge base doesn't know Lakehouse internals,
   so domain-specific facts read as UNVERIFIABLE by default).

   verifier_verdicts array is persisted alongside facts so downstream
   queries can surface high-confidence facts (CORRECT) separately
   from provisional ones (UNVERIFIABLE).

   schema_version:2 added to both scrum_reviews.jsonl and
   audit_facts.jsonl writes. Old (v1) rows remain readable; new rows
   get the field so the forward-compat reader in kb_query can
   differentiate.

D. scrum_master_reviewed:true flag added to scrum_reviews.jsonl
   rows on accept. Future kb_query surfacing can filter by this
   (e.g., "show me PRs where a scrum review exists vs only inference"
   as governance signal). Also carried into audit_facts.jsonl when
   the scrum_review source path writes there.
2026-04-22 23:40:21 -05:00
profit 2afad0f83f auditor/inference: N=3 consensus + qwen3-coder:480b tie-breaker
Closes the determinism gap observed in the 3-run baseline test: 1 of
8 findings (the "proven escalation ladder" block) was flipping across
identical-state audits. Root cause: cloud non-determinism at temp=0
is real in practice even though it shouldn't be in theory.

Fix: run the primary reviewer (gpt-oss:120b) N=3 times in PARALLEL
(Promise.all, wall-clock ≈ single call because they're independent
HTTP requests). Aggregate votes per claim_idx. Majority wins. On a
1-1-1 split, call a tie-breaker model with different architecture:
qwen3-coder:480b — newer coding specialist, 4x params of the primary,
distinct training lineage.

Every case where the 3 runs disagreed (even when majority resolved)
is logged to data/_kb/audit_discrepancies.jsonl with the vote counts
and resolution type. This is how we measure consensus drift over
time — a dashboard metric is literally `wc -l audit_discrepancies`
relative to audit count.

Verified: 2 back-to-back audits on unchanged PR #8 produced
identical 8 findings each (1 block + 7 warn). consensus=3/3 on every
claim, zero discrepancies logged. Cost: 3x primary tokens (7K per
audit vs 2K), wall-clock ~unchanged because calls are parallel.

New env vars:
  LH_AUDITOR_CONSENSUS_N        default 3
  LH_AUDITOR_TIEBREAKER_MODEL   default qwen3-coder:480b

Factored the cloud call into runCloudInference() helper so the
consensus loop is clean and the tie-breaker reuses the same prompt
shape as the primary.
2026-04-22 23:38:17 -05:00
profit 77650c4ba3 auditor: inference curation layer + llm_team fact extraction → KB
Closes the cycle J asked for: curated cloud output lands structured
knowledge in the KB so future audits have architectural context, not
just a log of per-finding signatures.

Three pieces:

1. Inference curation (tree-split) — when diff > 30KB, shard at 4.5KB,
   summarize each shard via cloud (temp=0, think=false on small
   shards; think=true on main call). Merge into scratchpad. The cloud
   verification then runs against the scratchpad, not truncated raw.
   Eliminates the 40KB MAX_DIFF_CHARS truncation path for large PRs
   (PR #8 is 102KB — was losing 62KB). Anti-false-positive guard in
   the prompt: cloud is told scratchpad absence is NOT diff absence,
   so it doesn't flag curated-out symbols as missing. unflagged_gaps
   section is dropped entirely when curated (scratchpad can't ground
   them).

2. fact_extractor — TS client for llm_team_ui's extract-facts mode at
   localhost:5000/api/run. Sends curated scratchpad through qwen2.5
   extractor + gemma2 verifier, parses SSE stream, returns structured
   {facts, entities, relationships, verification, llm_team_run_id}.
   Best-effort: if llm_team is down, extraction fails silently and
   the audit still completes. AWAITED so CLI tools (audit_one.ts)
   don't exit before extraction lands — the systemd poller has 90s
   headroom so the extra ~15s doesn't matter.

3. audit_facts.jsonl + checkAuditFacts() — one row per curated audit
   with the extraction result. kb_query tails the jsonl, explodes
   entity rows, aggregates by entity name with distinct-PR counting,
   surfaces entities recurring in 2+ PRs as info findings. Filters
   out short names (<3 chars, extractor truncation artifacts) and
   generic types (string/number/etc.) so signal isn't drowned.

Verified end-to-end on PR #8: 102KB diff → 23 shards → 1KB scratchpad
→ qwen2.5 extracted 4 facts + 6 entities + 6 relationships (real
code-level knowledge: AggregateOptions<T> type, aggregate<T> async
function with real signature, typed relationships). llm_team_run_id
cross-references to llm_team's own team_runs table.

Also: audit.ts passes (pr_number, head_sha) as InferenceContext so
extracted facts are scope-tagged for the KB index.
2026-04-22 23:09:14 -05:00
profit 47f1ca73e7 auditor: Level 1 correction — keep think=true, only temp=0 is needed
lakehouse/auditor 4 warnings — see review
The previous Level 1 commit set think=false which broke the cloud
inference check on real PR audits. gpt-oss:120b is a reasoning model;
at think=false on large prompts (40KB diff + 14 claims) it returned
empty content — verified by inspecting verdict 8-8e4ebbe4b38a which
showed "cloud returned unparseable output — skipped" with 13421
tokens used and head:<empty>.

Small-prompt tests passed because the model could respond without
needing to think. Real audits with the full diff + claims context
require the reasoning channel to produce any output at all.

The determinism we need comes from temp=0 (greedy sampling). The
reasoning trace at think=true varies in prose but greedy sampling
converges to the same FINAL classification from identical starting
state, so signatures remain stable.

max_tokens restored to 3000 for the think trace + response.
2026-04-22 22:24:25 -05:00
profit 8e4ebbe4b3 test: nine-consecutive audit run 5/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:17:11 -05:00
profit c6511427a4 test: nine-consecutive audit run 4/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:15:13 -05:00
profit b02554daec test: nine-consecutive audit run 3/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:13:26 -05:00
profit 2bb83d1bbb test: nine-consecutive audit run 2/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:11:34 -05:00
profit 0cdf9f7928 test: nine-consecutive audit run 1/5 (compounding probe)
lakehouse/auditor all checks passed (11 findings, all info)
2026-04-22 22:10:17 -05:00
profit 1e00eb4472 auditor: inference temp=0, think=false — kill signature creep
9-run empirical test showed 20 of 27 audit_lessons signatures were
singletons (count=1) — the cloud producing slightly-different summary
phrasings for the SAME underlying claim on each audit, each hashing
to a fresh signature. That's the creep J flagged — not explosive,
but steady ~2 new sigs per run, unbounded over hundreds of runs.

Root cause: temperature=0.2 + think=true was letting variable prose
leak into the classification output. Fix: temp=0 (greedy sample →
identical input yields identical output on same model version),
think=false (no reasoning trace variance), max_tokens 3000→1500
(tighter bound prevents tail wander).

The compounding policy itself was validated by the 9 runs:
  - 7 recurring claims (the legitimate signals) all at conf 0.08-0.20
  - ratingSeverity() correctly held them at info (below 0.3 threshold)
  - cross-PR signal test separately confirmed conf=1.00 → sev=block

Also: LH_AUDIT_RUNS env so the test can validate with smaller N.
2026-04-22 22:09:35 -05:00
profit 81a2200344 test: nine-consecutive audit run 9/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 22:06:44 -05:00
profit c32289143c test: nine-consecutive audit run 8/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 22:04:47 -05:00
profit 6df0cdadb3 test: nine-consecutive audit run 7/9 (compounding probe)
lakehouse/auditor 3 warnings — see review
2026-04-22 22:02:50 -05:00
profit 6d507d5411 test: nine-consecutive audit run 6/9 (compounding probe)
lakehouse/auditor 7 warnings — see review
2026-04-22 22:01:03 -05:00
profit d95d7b193e test: nine-consecutive audit run 5/9 (compounding probe)
lakehouse/auditor 8 warnings — see review
2026-04-22 21:59:00 -05:00
profit 2e222c8eaa test: nine-consecutive audit run 4/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 21:57:18 -05:00
profit 0533aa78fb test: nine-consecutive audit run 3/9 (compounding probe)
lakehouse/auditor 4 warnings — see review
2026-04-22 21:55:26 -05:00
profit ac5577c4fa test: nine-consecutive audit run 2/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 21:53:33 -05:00
profit c5f0f35cdb test: nine-consecutive audit run 1/9 (compounding probe)
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
2026-04-22 21:52:21 -05:00
profit 9d12a814e3 auditor: kb_index aggregator + nine-consecutive empirical test
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
Phase 1 — definition-layer over append-only JSONL scratchpads.

auditor/kb_index.ts is the single shared aggregator:

  aggregate<T>(jsonlPath, { keyFn, scopeFn, checkFn, tailLimit })
      → Map<signature, {count, distinct_scopes, confidence,
                        first_seen, last_seen, representative_summary, ...}>

  ratingSeverity(agg) — confidence × count severity policy shared
    across all KB readers. Kills the "same unfixed PR inflates its
    own recurrence score" failure mode by design: confidence =
    distinct_scopes/count, so same-scope noise stays below the 0.3
    escalation threshold no matter how many times it repeats.

checkAuditLessons now routes through aggregate + ratingSeverity.
Net effect: the recurrence detector's bespoke Map/Set bookkeeping is
gone; same behavior, shared discipline, reusable by scrum/observer.

Also: symbolsExistInRepo now skips files >500KB so the audit can't
get stuck slurping a fixture.

Phase 2 — nine-consecutive audit runner.

tests/real-world/nine_consecutive_audits.ts pushes 9 empty commits,
waits for each verdict, captures the audit_lessons aggregate state
after each run, reports:

  - sig_count trajectory (should stabilize, not grow linearly)
  - max_count trajectory (same-signature repeat rate)
  - max_confidence trajectory (must stay LOW on same-PR noise)
  - verdict_stable across runs (must NOT oscillate)

This is the empirical proof that the KB compounds favorably:
noise doesn't escalate itself, and signal stays distinguishable.

Unit-tested both failure modes: same-PR × 9 repeats = conf=0.11
(info); cross-PR × 5 distinct = conf=1.00 (block). The rating
function correctly discriminates.
2026-04-22 21:49:46 -05:00
profit f4be27a879 auditor: fix two false-positive classes from cloud inference
lakehouse/auditor 1 blocking issue: cloud: claim not backed — "the proven escalation ladder with learning context, collects"
Observed on PR #8 audit (de11ac4): 7 warn findings, all from the
cloud inference check. Investigation showed two distinct bug classes
that weren't "ship bad code", they were "auditor misreads the diff":

1. Cloud flagged "X not defined in this diff / missing implementation"
   for symbols like `tailJsonl` and `stubFinding` that ARE defined —
   just not in the added lines of this diff. Fix: extract candidate
   symbols from the cloud's gap summary, grep the repo for their
   definitions (function/const/let/def/class/struct/enum/trait/fn).
   If every named symbol resolves, drop the finding; if some do,
   demote to info with the resolution in evidence.

2. Cloud flagged runtime metrics like "58 cloud calls, 306s
   end-to-end" as unbacked claims. These are empirical outputs
   from running the test, not things a static diff can prove.
   Fix: claim_parser now has an `empirical` strength class
   matching iteration counts, cloud-call counts, duration metrics,
   attempt counts, tier-count phrases. Inference drops empirical
   claims from its cloud prompt (verifiable[] subset only) and
   claim-index mapping uses verifiable[] so cloud responses still
   line up.

Added `claims_empirical` to audit metrics so the verdict is
introspectable: how many claims WERE runtime-only vs how many
are diff-verifiable?

Verified: unit tests confirm empirical classification on 5
sample commit messages; symbol resolver found both false-positive
symbols (tailJsonl + stubFinding) and correctly skipped a known-
fake symbol.
2026-04-22 21:40:03 -05:00
profit de11ac4018 auditor/README: document audit_lessons + scrum_reviews KB files
lakehouse/auditor 7 warnings — see review
Adds State section entries for the two KB files that close the
feedback loop: audit_lessons.jsonl (findings → recurrence detector)
and scrum_reviews.jsonl (scrum output → kb_query surfacing).

Touch-commit to trigger re-audit on fresh SHA with the restarted
auditor (which now has the fix-loaded code).
2026-04-22 21:33:27 -05:00
profit 0306dd88c1 auditor: close the verdict→playbook loop + fix rubric-string false positive
lakehouse/auditor 2 blocking issues: unimplemented!() macro call in tests/real-world/hard_task_escalation.ts
Two changes that fell out of running the auto-loop for real on PR #8:

1. The systemd auditor blocked PR #8 on 'unimplemented!()' / 'todo!()'
   in tests/real-world/hard_task_escalation.ts — but those strings are
   the rubric itself, not macro calls. Added isInsideQuotedString()
   detection in static.ts: BLOCK_PATTERNS now skip matches that fall
   inside double-quoted / single-quoted / backtick string literals on
   the added line. WARN/INFO patterns still run — a TODO comment in
   a string is still a valid signal.

2. Verdicts were being persisted to disk but never fed back as
   learning signal. Added appendAuditLessons() — every block/warn
   finding writes a JSONL row to data/_kb/audit_lessons.jsonl with a
   path-agnostic signature (strips file paths, line numbers, commit
   hashes) so the SAME class of finding on DIFFERENT files dedups to
   one signature.

   kb_query now tails audit_lessons.jsonl and emits recurrence
   findings: 2 distinct PRs hit a signature = info, 3-4 = warn, 5+ =
   block. Severity ramps on distinct-PR count, not total rows, so a
   single unfixed PR being re-audited doesn't inflate its own
   recurrence score.

Fires on post-verdict fire-and-forget (can't break the audit if
disk write fails). The learning loop is now closed: each audit
contributes to the KB that guides the next audit.

Tested: unit tests for normalizedSignature confirmed path-agnostic
dedup; static.ts regression tests confirmed rubric strings no longer
trip BLOCK while real unquoted unimplemented!() still does.
2026-04-22 21:31:35 -05:00
profit dc01ba0a3b auditor: kb_query surfaces scrum-master reviews for files in PR diff
lakehouse/auditor 2 blocking issues: unimplemented!() macro call in tests/real-world/hard_task_escalation.ts
Wires the cohesion-plan Phase C link: the scrum-master pipeline writes
per-file reviews to data/_kb/scrum_reviews.jsonl on accept; the
auditor now reads that same file and emits one kb_query finding per
scrum review whose `file` matches a path in the PR's diff.

Severity heuristic: attempt 1-3 → info, attempt 4+ → warn. Reaching
the cloud specialist (attempt 4+) means the ladder had to escalate,
which is meaningful signal reviewers should see. Tree-split fired is
also surfaced in the finding summary.

audit.ts now passes pr.files.map(f => f.path) into runKbCheck (the
old signature dropped it on the floor). Also adds auditor/audit_one.ts
— a dry-run CLI for auditing a single PR without posting to Gitea,
useful for verifying check behavior without spamming review comments.

Verified: after writing scrum_reviews for auditor/audit.ts and
mcp-server/observer.ts (both in PR #7), audit_one 7 surfaced both as
info findings with preview + accepted_model + tree_split flag. A
scrum review for playbook_memory.rs (NOT in PR #7) was correctly
filtered out.
2026-04-22 21:18:21 -05:00
root 89d188074b scrum_master: tree-split + scrum_reviews.jsonl writer + truncation warning
Extends the scrum-master pipeline to handle input overflow on large
source files (>6KB). Previously, the review prompt truncated the file
to first-chunk, which caused false-positive "field is missing"
findings whenever the actual field was past the cutoff.

Now each file >FILE_TREE_SPLIT_THRESHOLD (6000) is sharded at
FILE_SHARD_SIZE (3500), each shard summarized via gpt-oss:120b cloud,
and the distillations merged into a scratchpad. The review then runs
against the scratchpad with an explicit truncation-awareness clause
in the prompt: "DO NOT claim any field, function, or feature is
'missing' based on its absence from this distillation."

Also writes each accepted review as a JSONL row to
data/_kb/scrum_reviews.jsonl (file, reviewed_at, accepted_model,
accepted_on_attempt, attempts_made, tree_split_fired, preview).
This is the source the auditor's kb_query reads to surface
per-file scrum reviews on PRs that touch those files (cohesion
plan Phase C).

Verified: scrum review of 92KB playbook_memory.rs → 27 shards via
cloud → distilled scratchpad → qwen3.5 local 7B accepted on attempt 1
(5931 chars). Tree-split fires, jsonl row appended, output file
contains structured suggestions.
2026-04-22 21:17:53 -05:00
profitandClaude Opus 4.7 a7aba31935 tests/real-world: scrum-master pipeline — composes everything we built
The orchestrator J described: pulls git repo source + PRD +
suggested-changes doc, chunks them, hands each code piece through
the proven escalation ladder with learning context, collects
per-file suggestions in a consolidated handoff report.

Composes ONLY already-shipped primitives — no new core code:
  - chunker with 800-char / 120-overlap windows
  - sidecar /embed for real nomic-embed-text embeddings
  - in-memory cosine retrieval for top-5 PRD + top-5 proposal
    chunks per target file
  - escalation ladder (qwen3.5 → qwen3 → gpt-oss:20b → gpt-oss:120b
    → devstral-2:123b → mistral-large-3:675b)
  - per-attempt learning-context injection (prior failures as
    "do not repeat" block)
  - acceptance rubric (length ≥ 200 chars + structured form)

Live-run (tests/real-world/runs/scrum_moatqkee/):
  targets: 3 files
    - crates/vectord/src/playbook_memory.rs  (920 lines)
    - crates/vectord/src/doc_drift.rs        (163 lines)
    - auditor/audit.ts                        (170 lines)
  resolved: 3/3 on attempt 1 by qwen3.5:latest local 7B
  total duration: 111.7s
  output: scrum_report.md + per-file JSON

Sample from scrum_report.md (playbook_memory.rs review):
  - Alignment score: 9/10 vs PRD Phase 19
  - 4 concrete change suggestions naming specific lines + PLAN/PRD
    chunk offsets
  - 3 gap analyses with PRD-reference citations

Honest findings from this run:
1. Local 7B handled review-style tasks first-try. The escalation
   ladder infrastructure is live but didn't fire — review is an
   easier task shape than strict code-generation (see hard_task
   test which needed devstral-2 specialist).
2. 6KB file-truncation caused one false positive: model claimed
   playbook_memory.rs lacks a `doc_refs` field, but that field
   exists past the 6KB cutoff. Trade-off between context-size
   and review-depth needs tuning per file.
3. Chunk-offset citations are real: model output includes
   `[PRD @27880]` and `[PLAN @16320]` which map to the actual
   byte offsets of retrieved context chunks. Auditor pattern could
   adopt this for traceable claims.

This is the scrum-master-handoff shape J asked for:
  repo + PRD + proposal → chunk → retrieve → escalate → consolidate
  → human-reviewable markdown report

Not shipping: per-PR diff analysis, open-PR integration, Gitea
posting of suggestions. Those compose the same primitives
differently — this proves the core pattern.

Env override: LH_SCRUM_FILES=path1,path2,... to target a different
file set. Default 3 files keeps runtime ~2min.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 20:52:42 -05:00
profitandClaude Opus 4.7 540c493ff1 tests/real-world: hard-task escalation — prove the ladder solves tasks local can't
J asked (2026-04-22): construct a task the local model provably can't
complete, then watch the escalation + retry + cloud pipeline actually
solve it.

The task: generate a Rust async function with 15 specific
structural rules (exact signature, bounded concurrency, exponential
backoff 250/500/1000ms, NO .unwrap(), rustdoc comments, etc.).
Small enough to fit in one response but strict enough that one
rule violation = not accepted. Fits Rust + async + concurrency +
error-handling — across the hardest dimensions for 7B models.

Escalation ladder (corrected per J — kimi-k2.x requires Ollama
Cloud Pro subscription which J's key lacks; mistral-large-3:675b
is the biggest provisioned model):

  1. qwen3.5:latest        (local 7B)
  2. qwen3:latest          (local 7B)
  3. gpt-oss:20b           (local 20B)
  4. gpt-oss:120b          (cloud 120B)
  5. devstral-2:123b       (cloud 123B coding specialist)
  6. mistral-large-3:675b  (cloud 675B — biggest available)

Each attempt gets PRIOR failures' rubric violations injected as
learning context. Loop caps at MAX_ATTEMPTS=6.

Live run (runs/hard_task_moapd3g3/):
  attempt 1: qwen3.5:latest         11/15  — missed concurrency + some constraints
  attempt 2: qwen3:latest           11/15  — different misses after learning
  attempt 3: gpt-oss:20b             0/1  — empty response (local model dead-end)
  attempt 4: gpt-oss:120b            0/1  — empty (heavy learning context may confuse)
  attempt 5: devstral-2:123b        15/15  ✅ ACCEPTED after 10.4s
  attempt 6: (not reached)

Total: 5 attempts, 145.6s, coding-specialist succeeded.

Honest findings from the run:
- Pipeline works: escalated through 4 distinct model tiers, injected
  learning, bounded at 6, graceful failure surfaces.
- Learning injection doesn't always help general-purpose models —
  gpt-oss:120b returned empty when given heavy prior-failure context
  (attempt 4). The coding specialist (devstral) worked better because
  the task is domain-aligned.
- Local 7B came within 4 rules of success first-try (11/15) — not
  bad for the scale, but specific constraints like "EXACT signature"
  and "bounded concurrency at 4" are where small models slip.
- Kimi K2.5/K2.6 both require a paid subscription on our current
  Ollama Cloud key — verified via direct ollama.com curl. Swap
  to kimi once subscription lands.

Also includes a rubric bug-fix caught in the run: the regex for
"reaches 500/1000ms backoff" originally required literal constants,
but devstral-2:123b wrote idiomatic `retry_delay *= 2;` which
doubles 250 → 500 → 1000 correctly. Broadened rubric to recognize
`*= 2`, bit-shift, `.pow()`, and literal forms. Without this the
ladder would have false-failed on semantically-correct code.

Files:
  tests/real-world/hard_task_escalation.ts (270 LOC)
  tests/real-world/runs/hard_task_moapd3g3/
    attempt_{1..5}.txt     — raw model outputs (last successful)
    attempt_{1..5}.json    — per-attempt rubric verdict + error
    summary.json           — ladder summary

What this PROVES that no prior test did:
- Task-level retry ESCALATES across distinct model capabilities
  (not just same model retried)
- Bigger and more-specialized models ACTUALLY solve what smaller
  ones can't — the ladder works by design, not by luck
- The subscription boundary (Kimi K2.x) is a real operational
  constraint, not a code issue
- Rubric engineering is its own discipline — a strict-but-wrong
  validator can reject correct code; shipping the test harness
  required tuning against actual model outputs

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 18:50:53 -05:00
profit 6d6a306d4e tests/real-world: add task-level 6-retry loop (per J 2026-04-22)
Two distinct retry loops now both cap at 6 and serve different
purposes:

1. Per-cloud-call continuation (Phase 21 primitive) — when a single
   cloud call returns empty or truncated, stitches up to 6
   continuation calls. Handles output-overflow.

2. Per-TASK retry (this commit) — when the whole task errors
   (500/404, thin answer, etc.), retries the full task up to 6
   times. Each retry gets PRIOR ATTEMPTS' failures injected into
   the prompt as learning context, so attempt N+1 is informed by
   what N failed at. Handles error-recovery with compounding
   context.

Both loops fired on iter 3 of the stress run, proving them
independent and composable:

  FORCING TASK-RETRY LOOP — iter 3 will cycle through 5 invalid
  models + 1 valid
    attempt 1/6: model=deliberately-invalid-model-attempt-1
        /v1/chat 502: ollama.com 404: model not found
    attempt 2/6: [with prior-failure context]
    ... (5 failures total, each with the full chain of prior errors)
    attempt 6/6: model=gpt-oss:20b [with prior-failure context]
        continuation retry 1..6 (empty responses)
        SUCCEEDED after 5 prior failures (441 chars)

What J was asking to prove:
  "I expect it to retry the process six times to build on the
   knowledge database... when an error is legitimately triggered
   that it will go through six times... without getting caught in
   a loop"

Proof:
  - 6/6 attempts fired on the FORCED iteration
  - Each retry embedded the preceding attempts' errors as "do not
    repeat" context
  - Hard cap at MAX_TASK_RETRIES (6) prevents infinite loops
  - Last-ditch local fallback exists if all 6 still fail
  - Other iterations succeed on attempt 1 — the loop ONLY fires
    when errors are legitimately triggered

Stress run totals (runs/moan4h71/):
  6/6 iterations complete, 58 cloud calls, 306s end-to-end
  tree-splits: 6/6   continuations: 10   rescues: 2
  iter 3: 8197+2800 tok, 6 task attempts, 6 continuation retries
  local stored summary + per-iter JSON for inspection

What this proves that prior stress runs did NOT:
  - Error-recovery at task granularity is live, not aspirational
  - Compounding failure context flows between retries as text
  - Loop bound is enforced; runaway cases aren't possible
  - Two retry mechanisms compose without deadlock (continuation
    inside task-retry inside tree-split)

Follow-ups worth doing (separate PRs):
  - Persist retry-history to observer :3800 so cross-run learning
    sees the failure patterns
  - Route retries through /vectors/hybrid to surface similar prior
    errors from the real KB (currently only in-memory across one
    iteration)
  - Fix citation regex in summary — iter 6 received 5 prior IDs
    but counter shows 0 (regex needs to tolerate hyphens in IDs)
2026-04-22 17:50:53 -05:00
profitandClaude Opus 4.7 4458c94f45 tests/real-world: enrich_prd_pipeline — architecture stress test
Real end-to-end test of the Lakehouse pipeline at scale. Runs the
PRD (63 KB, 901 lines → 93 chunks) through 6 iterations with cloud
inference, intentional failure injection, and tight context budget
to force every Phase 21 primitive to fire.

What the test exercises:
- Sidecar /embed for 93 chunks (nomic-embed-text)
- In-memory cosine retrieval for top-K per iteration
- Tree-split (shard → summarize → scratchpad → merge) when context
  chunks exceed the 4000-char budget
- Scratchpad truncation to keep compounding context bounded
- Cloud inference via /v1/chat provider=ollama_cloud (gpt-oss:120b)
- Injected primary-cloud failure on iter 3 (invalid model name) +
  rescue with gpt-oss:20b — proves catch-and-retry isn't dead code
- Playbook seeding per iteration (real HTTP against gateway)
- Prior-iteration answer injection for compounding (not just IDs —
  the first version passed IDs only and the model ignored them)

Live run results (tests/real-world/runs/moamj810/):
  6/6 iterations complete, 42 cloud calls total, 245s end-to-end
  tree-splits: 6/6 (every iter overflowed 4K budget)
  continuations: 0 (no responses hit max_tokens)
  rescues: 1 (iter 3 injected failure → gpt-oss:20b → valid answer)
  iter 6 answer explicitly cites [pb:pb-seed-82e1] — compounding real
  scratchpad truncation fired on iter 6 as designed

What this PROVES:
- Tree-split primitives work under real context pressure, not just
  in unit tests. The 4000-char budget forced every iteration to
  shard 12 chunks → 6 shards → scratchpad → final answer.
- Rescue on primary failure is wired and produces answers from a
  weaker model rather than erroring out.
- Compounding context injection works: iter 6's prompt had the 5
  prior answers in its citation block, and the cloud model
  acknowledged at least one via [pb:...] notation.
- The existence claims in Phase 21 (continuation + tree-split) are
  backed by executable evidence, not just unit tests.

What this DOESN'T prove (deliberate — scoped for follow-up):
- Continuation retries (no iter hit max_tokens in this run; would
  need a harder prompt or lower max_tokens to force)
- Real integration with /vectors/hybrid endpoint (test does in-memory
  cosine instead, bypassing gateway vector surface)
- Observer consumption of these runs (nothing posted to :3800 during
  the test — adding that is Phase A integration, handled separately)

Files:
  tests/real-world/enrich_prd_pipeline.ts (333 LOC)
  tests/real-world/runs/moamj810/{iter_1..6.json, summary.json}
    — artifacts from the stress run, committed for inspection

Follow-ups worth doing:
1. Lower max_tokens / harder prompt to force continuation path
2. Route retrieval through /vectors/hybrid for real Phase 19 boost
3. POST per-iteration summary to observer :3800 so runs accumulate
   like scenario runs do

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 17:33:24 -05:00
105 changed files with 643 additions and 17010 deletions
-8
View File
@@ -7,14 +7,6 @@
"LAKEHOUSE_URL": "http://localhost:3100",
"MCP_TRANSPORT": "stdio"
}
},
"gitea": {
"command": "bunx",
"args": ["gitea-mcp"],
"env": {
"GITEA_HOST": "https://git.agentview.dev",
"GITEA_ACCESS_TOKEN": "SET_ME_FROM_GITEA_UI_USER_SETTINGS_APPLICATIONS"
}
}
}
}
Generated
-27
View File
@@ -46,7 +46,6 @@ dependencies = [
name = "aibridge"
version = "0.1.0"
dependencies = [
"async-trait",
"axum",
"reqwest",
"serde",
@@ -4091,7 +4090,6 @@ dependencies = [
"tracing",
"tracing-opentelemetry",
"tracing-subscriber",
"truth",
"vectord",
]
@@ -4680,7 +4678,6 @@ dependencies = [
"chrono",
"croner",
"csv",
"journald",
"lopdf",
"mysql_async",
"object_store",
@@ -6898,7 +6895,6 @@ dependencies = [
"storaged",
"tokio",
"tracing",
"truth",
"url",
]
@@ -8730,17 +8726,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "truth"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"tokio",
"toml",
"tracing",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -8907,17 +8892,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "validator"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tracing",
]
[[package]]
name = "valuable"
version = "0.1.1"
@@ -8945,7 +8919,6 @@ dependencies = [
"object_store",
"parquet 55.2.0",
"queryd",
"reqwest",
"serde",
"serde_json",
"sha2",
-2
View File
@@ -14,8 +14,6 @@ members = [
"crates/ui",
"crates/lance-bench",
"crates/vectord-lance",
"crates/truth",
"crates/validator",
]
[workspace.dependencies]
-62
View File
@@ -1,62 +0,0 @@
# Phase 39: Provider Registry
#
# Per-provider base_url, auth scheme, and default model. The gateway's
# /v1/chat dispatcher reads this file at boot to populate its provider
# table. Secrets (API keys) come from /etc/lakehouse/secrets.toml or
# environment variables — NEVER inline a key here.
#
# Adding a new provider:
# 1. New [[provider]] block with name, base_url, auth, default_model
# 2. Matching adapter at crates/aibridge/src/providers/<name>.rs
# implementing the ProviderAdapter trait (chat + embed + unload)
# 3. Route arm in crates/gateway/src/v1/mod.rs matching on `name`
# 4. Model-prefix routing hint in resolve_provider() if the provider
# uses an "<name>/..." model prefix (e.g. "openrouter/...")
[[provider]]
name = "ollama"
base_url = "http://localhost:3200"
auth = "none"
default_model = "qwen3.5:latest"
# Hot-path local inference. No bearer needed — Python sidecar on
# localhost handles the Ollama API. Model names are bare
# (e.g. "qwen3.5:latest", not "ollama/qwen3.5:latest").
[[provider]]
name = "ollama_cloud"
base_url = "https://ollama.com"
auth = "bearer"
auth_env = "OLLAMA_CLOUD_KEY"
default_model = "gpt-oss:120b"
# Cloud-tier Ollama. Key resolved from OLLAMA_CLOUD_KEY env at gateway
# boot. Model-prefix routing: "cloud/<model>" auto-routes here
# (see gateway::v1::resolve_provider).
[[provider]]
name = "openrouter"
base_url = "https://openrouter.ai/api/v1"
auth = "bearer"
auth_env = "OPENROUTER_API_KEY"
auth_fallback_files = ["/home/profit/.env", "/root/llm_team_config.json"]
default_model = "openai/gpt-oss-120b:free"
# Multi-provider gateway. Covers Anthropic, Google, OpenAI, MiniMax,
# Qwen, Gemma, etc. Key resolved via crates/gateway/src/v1/openrouter.rs
# resolve_openrouter_key() — env first, then fallback files.
# Model-prefix routing: "openrouter/<vendor>/<model>" auto-routes here,
# prefix stripped before upstream call.
# Planned (Phase 40 long-horizon — adapters not yet shipped):
#
# [[provider]]
# name = "gemini"
# base_url = "https://generativelanguage.googleapis.com/v1beta"
# auth = "api_key_query"
# auth_env = "GEMINI_API_KEY"
# default_model = "gemini-2.0-flash"
#
# [[provider]]
# name = "claude"
# base_url = "https://api.anthropic.com/v1"
# auth = "x_api_key"
# auth_env = "ANTHROPIC_API_KEY"
# default_model = "claude-3-5-sonnet-latest"
-61
View File
@@ -1,61 +0,0 @@
# Phase 40: Routing Engine Configuration
#
# Human-editable rules for model → provider routing.
# Matching order: first match wins.
[[rule]]
model_pattern = "gpt-4*"
provider = "openrouter"
max_tokens = 4096
temperature = 0.7
[[rule]]
model_pattern = "claude*"
provider = "claude"
max_tokens = 4096
temperature = 0.7
[[rule]]
model_pattern = "gemini*"
provider = "gemini"
max_tokens = 8192
temperature = 0.9
[[rule]]
model_pattern = "qwen3.5*"
provider = "ollama"
max_tokens = 4096
temperature = 0.3
[[rule]]
model_pattern = "qwen3*"
provider = "ollama"
max_tokens = 2048
temperature = 0.3
[[rule]]
model_pattern = "gpt-oss*"
provider = "ollama"
temperature = 0.1
[[rule]]
model_pattern = "*"
provider = "ollama"
temperature = 0.5
# Fallback chain: if primary fails, try these in order
fallback = ["ollama", "openrouter"]
# Cost gating (tokens = cents per 1M)
[cost]
ollama = 0
openrouter = 15
claude = 15
gemini = 0
# Daily budget per provider (cents)
[daily_budget]
ollama = 0
openrouter = 1000
claude = 500
gemini = 0
-1
View File
@@ -11,4 +11,3 @@ serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
async-trait = "0.1"
+16 -10
View File
@@ -13,9 +13,11 @@
use std::collections::HashMap;
use std::sync::OnceLock;
// `estimate_tokens` moved to `shared::model_matrix::ModelMatrix::estimate_tokens`
// (cdc24d8). All callers migrated; the deprecated wrapper that stood in its
// place has been removed since it had zero external consumers.
/// Rough token count. `chars / 4` ceiling. See module docs for why
/// this heuristic is sufficient.
pub fn estimate_tokens(text: &str) -> usize {
(text.chars().count() + 3) / 4
}
/// Phase 21 — per-model context windows, mirroring the TS table in
/// `tests/multi-agent/agent.ts`. Anchored on each model's documented
@@ -82,8 +84,8 @@ pub fn assert_context_budget(
let window = context_window_for(model);
let safety = opts.safety_margin.unwrap_or(DEFAULT_SAFETY_MARGIN);
let max_tokens = opts.max_tokens.unwrap_or(DEFAULT_MAX_TOKENS);
let sys_tokens = opts.system.map(shared::model_matrix::ModelMatrix::estimate_tokens).unwrap_or(0);
let estimated = shared::model_matrix::ModelMatrix::estimate_tokens(prompt) + sys_tokens + max_tokens;
let sys_tokens = opts.system.map(estimate_tokens).unwrap_or(0);
let estimated = estimate_tokens(prompt) + sys_tokens + max_tokens;
let remaining = window as i64 - estimated as i64 - safety as i64;
let check = BudgetCheck { estimated, window, remaining };
if remaining < 0 && !opts.bypass {
@@ -107,10 +109,14 @@ pub fn overflow_message(model: &str, check: &BudgetCheck, over_by: usize, safety
mod tests {
use super::*;
// Deprecated-function behavior is now canonically tested in
// crates/shared/src/model_matrix.rs. This test was the legacy
// pin that preceded the migration; delete when the deprecated
// wrapper itself goes (see the #[deprecated] attribute).
#[test]
fn estimate_tokens_ceiling_divides_by_four() {
assert_eq!(estimate_tokens(""), 0);
assert_eq!(estimate_tokens("abc"), 1); // 3 → ceil(3/4) = 1
assert_eq!(estimate_tokens("abcd"), 1); // 4 → ceil(4/4) = 1
assert_eq!(estimate_tokens("abcde"), 2); // 5 → ceil(5/4) = 2
assert_eq!(estimate_tokens(&"x".repeat(400)), 100);
}
#[test]
fn context_window_known_and_fallback() {
@@ -173,7 +179,7 @@ mod tests {
).unwrap();
assert!(with_sys.estimated > without_sys.estimated,
"system prompt should raise estimate");
assert_eq!(with_sys.estimated - without_sys.estimated, shared::model_matrix::ModelMatrix::estimate_tokens(&sys));
assert_eq!(with_sys.estimated - without_sys.estimated, estimate_tokens(&sys));
}
#[test]
+5 -28
View File
@@ -138,17 +138,6 @@ pub struct ContinuableOutcome {
pub empty_retries: usize,
pub continuations: usize,
pub final_complete: bool,
/// Sum of `prompt_tokens` across every generator call made to
/// produce this outcome — including empty retries and continuations.
/// Lets callers (gateway execution loop, observability) stamp
/// accurate per-task usage without second-guessing the retry fan-out.
pub prompt_tokens: u32,
/// Sum of `completion_tokens` across every generator call.
pub completion_tokens: u32,
/// Total number of generator calls. `1 + empty_retries +
/// continuations` in the normal case; the field is explicit so
/// callers don't have to re-derive it.
pub calls: u32,
}
fn make_request(opts: &ContinuableOpts, prompt: String, current_max: u32) -> GenerateRequest {
@@ -186,17 +175,11 @@ pub async fn generate_continuable<G: TextGenerator>(
let mut combined = String::new();
let mut empty_retries = 0usize;
let mut continuations = 0usize;
let mut prompt_tokens: u32 = 0;
let mut completion_tokens: u32 = 0;
let mut calls: u32 = 0;
// Phase 21(a) — empty-response backoff loop.
for retry in 0..opts.max_empty_retries {
let req = make_request(opts, prompt.to_string(), current_max);
let resp = generator.generate_text(req).await?;
calls += 1;
prompt_tokens = prompt_tokens.saturating_add(resp.tokens_evaluated.unwrap_or(0) as u32);
completion_tokens = completion_tokens.saturating_add(resp.tokens_generated.unwrap_or(0) as u32);
if !resp.text.trim().is_empty() {
combined = resp.text;
break;
@@ -205,7 +188,9 @@ pub async fn generate_continuable<G: TextGenerator>(
current_max = (current_max.saturating_mul(2)).min(opts.budget_cap);
}
// Phase 21(b) — structural-completion continuation loop.
// Phase 21(b) — structural-completion continuation loop. Runs on
// the truncated-non-empty case; empty + exhausted retries falls
// through with empty combined and final_complete=false.
for _ in 0..opts.max_continuations {
if is_structurally_complete(&combined, opts.shape) {
return Ok(ContinuableOutcome {
@@ -213,22 +198,17 @@ pub async fn generate_continuable<G: TextGenerator>(
empty_retries,
continuations,
final_complete: true,
prompt_tokens,
completion_tokens,
calls,
});
}
if combined.trim().is_empty() {
// Nothing to continue from — continuing "" is identical to
// the initial call and would loop.
// the initial call and would loop. Bail so the caller sees
// the failure rather than burning N extra calls.
break;
}
let cont_prompt = continuation_prompt(prompt, &combined);
let req = make_request(opts, cont_prompt, current_max.min(opts.budget_cap));
let resp = generator.generate_text(req).await?;
calls += 1;
prompt_tokens = prompt_tokens.saturating_add(resp.tokens_evaluated.unwrap_or(0) as u32);
completion_tokens = completion_tokens.saturating_add(resp.tokens_generated.unwrap_or(0) as u32);
combined.push_str(&resp.text);
continuations += 1;
}
@@ -239,9 +219,6 @@ pub async fn generate_continuable<G: TextGenerator>(
empty_retries,
continuations,
final_complete,
prompt_tokens,
completion_tokens,
calls,
})
}
-3
View File
@@ -1,8 +1,5 @@
pub mod client;
pub mod context;
pub mod continuation;
pub mod provider;
pub mod providers;
pub mod routing;
pub mod service;
pub mod tree_split;
-39
View File
@@ -1,39 +0,0 @@
use async_trait::async_trait;
use crate::client::{GenerateRequest, GenerateResponse, EmbedRequest, EmbedResponse};
#[async_trait]
pub trait ProviderAdapter: Send + Sync {
/// Name for routing (ollama, openrouter, etc.)
fn name(&self) -> &str;
/// Chat completion — returns text, model, token counts
async fn chat(&self, req: GenerateRequest) -> Result<GenerateResponse, String>;
/// Embeddings — returns vectors, model, dimensions
async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String>;
/// Unload model from VRAM (optional, no-op if not supported)
async fn unload(&self, _model: &str) -> Result<(), String> {
Ok(())
}
/// Health check
async fn health(&self) -> Result<serde_json::Value, String>;
}
/// Routing key extracted from model name.
/// - "qwen3.5:latest" → "ollama"
/// - "openrouter/anthropic/claude-3.5-sonnet" → "openrouter"
/// - "gpt-4o" → "ollama" (default)
pub fn provider_key(model: &str) -> &'static str {
let lower = model.to_lowercase();
if lower.starts_with("openrouter/") {
"openrouter"
} else if lower.starts_with("gemini") {
"gemini"
} else if lower.starts_with("claude") {
"claude"
} else {
"ollama" // default: local Ollama
}
}
-2
View File
@@ -1,2 +0,0 @@
pub mod ollama;
pub mod openrouter;
-37
View File
@@ -1,37 +0,0 @@
use async_trait::async_trait;
use crate::client::{AiClient, GenerateRequest, GenerateResponse, EmbedRequest, EmbedResponse};
use crate::provider::ProviderAdapter;
pub struct OllamaAdapter {
client: AiClient,
}
impl OllamaAdapter {
pub fn new(client: AiClient) -> Self {
Self { client }
}
}
#[async_trait]
impl ProviderAdapter for OllamaAdapter {
fn name(&self) -> &str {
"ollama"
}
async fn chat(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
self.client.generate(req).await
}
async fn embed(&self, req: EmbedRequest) -> Result<EmbedResponse, String> {
self.client.embed(req).await
}
async fn unload(&self, model: &str) -> Result<(), String> {
let _: serde_json::Value = self.client.unload_model(model).await?;
Ok(())
}
async fn health(&self) -> Result<serde_json::Value, String> {
self.client.health().await
}
}
-152
View File
@@ -1,152 +0,0 @@
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use std::time::Duration;
use crate::client::{GenerateRequest, GenerateResponse, EmbedRequest, EmbedResponse};
use crate::provider::ProviderAdapter;
pub struct OpenRouterAdapter {
client: Client,
base_url: String,
api_key: String,
default_model: String,
}
#[derive(Serialize)]
struct OpenRouterChatRequest {
model: String,
messages: Vec<OpenRouterMessage>,
temperature: Option<f64>,
max_tokens: Option<u32>,
}
#[derive(Serialize)]
struct OpenRouterMessage {
role: String,
content: String,
}
#[derive(Deserialize)]
struct OpenRouterChatResponse {
choices: Vec<OpenRouterChoice>,
usage: OpenRouterUsage,
model: String,
}
#[derive(Deserialize)]
struct OpenRouterChoice {
message: OpenRouterMessageOut,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct OpenRouterMessageOut {
role: String,
content: String,
}
#[derive(Deserialize)]
#[allow(dead_code)]
struct OpenRouterUsage {
prompt_tokens: Option<u32>,
completion_tokens: Option<u32>,
total_tokens: Option<u32>,
}
impl OpenRouterAdapter {
pub fn new(base_url: &str, api_key: String, default_model: &str) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(180))
.build()
.expect("failed to build HTTP client");
Self {
client,
base_url: base_url.trim_end_matches('/').to_string(),
api_key,
default_model: default_model.to_string(),
}
}
fn chat_model(&self, model: &str) -> String {
// Strip "openrouter/" prefix if present
let m = model.trim_start_matches("openrouter/");
if m.is_empty() || m == model {
self.default_model.clone()
} else {
m.to_string()
}
}
fn to_openrouter_messages(req: &GenerateRequest) -> Vec<OpenRouterMessage> {
let mut out = vec![];
if let Some(sys) = &req.system {
out.push(OpenRouterMessage { role: "system".into(), content: sys.clone() });
}
out.push(OpenRouterMessage {
role: "user".into(),
content: req.prompt.clone(),
});
out
}
}
#[async_trait]
impl ProviderAdapter for OpenRouterAdapter {
fn name(&self) -> &str {
"openrouter"
}
async fn chat(&self, req: GenerateRequest) -> Result<GenerateResponse, String> {
let model = self.chat_model(req.model.as_deref().unwrap_or(""));
let or_req = OpenRouterChatRequest {
model: model.clone(),
messages: OpenRouterAdapter::to_openrouter_messages(&req),
temperature: req.temperature,
max_tokens: req.max_tokens,
};
let resp = self.client
.post(format!("{}/chat/completions", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.json(&or_req)
.send()
.await
.map_err(|e| format!("openrouter request failed: {e}"))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
if !status.is_success() {
return Err(format!("openrouter error ({}): {}", status, body));
}
let or_resp: OpenRouterChatResponse = serde_json::from_str(&body)
.map_err(|e| format!("openrouter parse error: {e}"))?;
let choice = or_resp.choices.into_iter().next()
.ok_or("no completion choice returned")?;
let usage = or_resp.usage;
Ok(GenerateResponse {
text: choice.message.content,
model: or_resp.model,
tokens_evaluated: usage.prompt_tokens.map(|n| n as u64),
tokens_generated: usage.completion_tokens.map(|n| n as u64),
})
}
async fn embed(&self, _req: EmbedRequest) -> Result<EmbedResponse, String> {
Err("openrouter: embed not implemented".into())
}
async fn health(&self) -> Result<serde_json::Value, String> {
// OpenRouter doesn't have a dedicated health endpoint,
// so we just return a healthy response if the client works.
Ok(serde_json::json!({
"status": "ok",
"provider": "openrouter",
}))
}
}
-119
View File
@@ -1,119 +0,0 @@
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct RoutingRule {
pub model_pattern: String,
pub provider: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f64>,
}
#[derive(Clone, Default)]
pub struct RoutingEngine {
rules: Vec<RoutingRule>,
fallback_chain: Vec<String>,
}
impl RoutingEngine {
pub fn new() -> Self {
Self::default()
}
pub fn with_rules(mut self, rules: Vec<RoutingRule>) -> Self {
self.rules = rules;
self
}
pub fn with_fallback(mut self, chain: Vec<String>) -> Self {
self.fallback_chain = chain;
self
}
pub fn route(&self, model: &str) -> RouteDecision {
let lower = model.to_lowercase();
for rule in &self.rules {
if glob_match(&rule.model_pattern.to_lowercase(), &lower) {
return RouteDecision {
provider: rule.provider.clone(),
model: model.to_string(),
max_tokens: rule.max_tokens,
temperature: rule.temperature,
};
}
}
if let Some(first) = self.fallback_chain.first() {
RouteDecision {
provider: first.clone(),
model: model.to_string(),
max_tokens: None,
temperature: None,
}
} else {
RouteDecision {
provider: "ollama".to_string(),
model: model.to_string(),
max_tokens: None,
temperature: None,
}
}
}
}
#[derive(Clone, Debug)]
pub struct RouteDecision {
pub provider: String,
pub model: String,
pub max_tokens: Option<u32>,
pub temperature: Option<f64>,
}
fn glob_match(pattern: &str, name: &str) -> bool {
if !pattern.contains('*') { return pattern == name; }
let parts: Vec<&str> = pattern.split('*').collect();
// Multi-* support: first must be prefix, last must be suffix, each
// interior piece must appear in order. Fixes the iter-9 finding
// where gpt-*-large* silently fell through to an exact-match path.
// Also removes the dead `parts.len() == 1` branch that accessed
// parts[1] and would panic if ever reached (unreachable today
// since split('*') on a string containing '*' always yields ≥2).
if !name.starts_with(parts[0]) || !name.ends_with(parts.last().unwrap()) { return false; }
let mut cursor = parts[0].len();
parts[1..parts.len() - 1].iter().all(|mid| {
name[cursor..].find(mid).map(|pos| { cursor += pos + mid.len(); true }).unwrap_or(false)
})
}
impl Default for RoutingRule {
fn default() -> Self {
Self {
model_pattern: "*".to_string(),
provider: "ollama".to_string(),
max_tokens: None,
temperature: None,
}
}
}
#[cfg(test)]
mod glob_match_tests {
use super::glob_match;
#[test] fn exact_match() { assert!(glob_match("gpt-oss:120b", "gpt-oss:120b")); }
#[test] fn exact_mismatch() { assert!(!glob_match("a", "b")); }
#[test] fn leading_wildcard() { assert!(glob_match("*:120b", "gpt-oss:120b")); }
#[test] fn trailing_wildcard() { assert!(glob_match("gpt-oss:*", "gpt-oss:120b")); }
#[test] fn bare_wildcard() { assert!(glob_match("*", "anything")); }
#[test] fn multi_wildcard_in_order() { assert!(glob_match("gpt-*-oss-*", "gpt-4-oss-120b")); }
#[test] fn multi_wildcard_wrong_order() { assert!(!glob_match("b*a*", "abba")); }
#[test] fn multi_wildcard_panic_safety() {
// Regression: earlier impl had an unreachable `parts.len() == 1`
// branch that indexed parts[1] — would panic if ever hit. Now
// the split('*') invariant guarantees ≥2 parts when * present,
// and we handle all N-part cases explicitly.
assert!(glob_match("a*b*c", "abc"));
assert!(glob_match("a*b*c", "axxxbxxxc"));
assert!(!glob_match("a*b*c", "xxxbxxx"));
}
}
+4 -5
View File
@@ -19,9 +19,8 @@
//! we bubble the error up rather than silently truncating. That's the
//! whole point of Phase 21.
use crate::context::{assert_context_budget, BudgetOpts, overflow_message,
use crate::context::{assert_context_budget, BudgetOpts, estimate_tokens, overflow_message,
DEFAULT_MAX_TOKENS, DEFAULT_SAFETY_MARGIN};
use shared::model_matrix::ModelMatrix;
use crate::continuation::{generate_continuable, ContinuableOpts, ResponseShape, TextGenerator};
/// Callback signatures — caller supplies closures that stitch the
@@ -81,12 +80,12 @@ pub struct TreeSplitResult {
/// by `\n— shard N/M digest —\n` so we can find the first one and
/// chop everything before its successor.
fn truncate_scratchpad(scratchpad: &mut String, budget_tokens: usize) -> bool {
if ModelMatrix::estimate_tokens(scratchpad) <= budget_tokens { return false; }
if estimate_tokens(scratchpad) <= budget_tokens { return false; }
// Find the second delimiter — everything before it gets dropped.
const DELIM_PREFIX: &str = "\n— shard ";
let mut cursor = 0;
let mut truncated = false;
while ModelMatrix::estimate_tokens(&scratchpad[cursor..]) > budget_tokens {
while estimate_tokens(&scratchpad[cursor..]) > budget_tokens {
// Skip past a leading delimiter (if we're sitting on one from
// a previous iteration), then find the next.
let search_from = cursor + if scratchpad[cursor..].starts_with(DELIM_PREFIX) {
@@ -279,7 +278,7 @@ mod tests {
// Scratchpad should still fit roughly within the budget
// (post-truncation); the estimator uses chars/4 so the bound
// is ~budget*4 chars. Give some slack for the delimiter.
let scratchpad_tokens = ModelMatrix::estimate_tokens(&result.scratchpad);
let scratchpad_tokens = estimate_tokens(&result.scratchpad);
assert!(scratchpad_tokens <= opts.scratchpad_budget * 2,
"scratchpad {} tokens vs budget {}", scratchpad_tokens, opts.scratchpad_budget);
}
-26
View File
@@ -31,10 +31,6 @@ pub fn router(registry: Registry) -> Router {
// Phase 17: model profiles
.route("/profiles", post(create_profile).get(list_profiles))
.route("/profiles/{id}", get(get_profile).delete(delete_profile))
// Phase 41: profile-type routes
.route("/profiles/retrieval", get(list_retrieval_profiles))
.route("/profiles/memory", get(list_memory_profiles))
.route("/profiles/observer", get(list_observer_profiles))
.with_state(registry)
}
@@ -402,9 +398,6 @@ struct CreateProfileRequest {
/// indexes to. Defaults to Parquet+HNSW.
#[serde(default)]
vector_backend: shared::types::VectorBackend,
/// Phase 41: Profile type for routing + activation behavior.
#[serde(default)]
profile_type: shared::types::ProfileType,
}
fn default_embed_model_req() -> String { "nomic-embed-text".to_string() }
@@ -424,7 +417,6 @@ async fn create_profile(
created_by: req.created_by,
bucket: req.bucket,
vector_backend: req.vector_backend,
profile_type: req.profile_type,
};
match registry.put_profile(profile).await {
Ok(p) => Ok((StatusCode::CREATED, Json(p))),
@@ -436,24 +428,6 @@ async fn list_profiles(State(registry): State<Registry>) -> impl IntoResponse {
Json(registry.list_profiles().await)
}
async fn list_retrieval_profiles(State(registry): State<Registry>) -> impl IntoResponse {
let all = registry.list_profiles().await;
let retrieval: Vec<_> = all.into_iter().filter(|p| p.profile_type == shared::types::ProfileType::Retrieval).collect();
Json(retrieval)
}
async fn list_memory_profiles(State(registry): State<Registry>) -> impl IntoResponse {
let all = registry.list_profiles().await;
let memory: Vec<_> = all.into_iter().filter(|p| p.profile_type == shared::types::ProfileType::Memory).collect();
Json(memory)
}
async fn list_observer_profiles(State(registry): State<Registry>) -> impl IntoResponse {
let all = registry.list_profiles().await;
let observer: Vec<_> = all.into_iter().filter(|p| p.profile_type == shared::types::ProfileType::Observer).collect();
Json(observer)
}
async fn get_profile(
State(registry): State<Registry>,
Path(id): Path<String>,
-1
View File
@@ -12,7 +12,6 @@ aibridge = { path = "../aibridge" }
ingestd = { path = "../ingestd" }
vectord = { path = "../vectord" }
journald = { path = "../journald" }
truth = { path = "../truth" }
tokio = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }
+1 -6
View File
@@ -93,7 +93,7 @@ impl AccessControl {
self.roles.write().await.insert(role.agent_name.clone(), role);
}
/// Get an agent's role. Called by `GET /access/roles/{agent}`.
/// Get an agent's role.
pub async fn get_role(&self, agent: &str) -> Option<AgentRole> {
self.roles.read().await.get(agent).cloned()
}
@@ -113,7 +113,6 @@ impl AccessControl {
}
/// Determine which fields should be masked for an agent.
#[allow(dead_code)]
pub async fn masked_fields(
&self,
agent: &str,
@@ -139,7 +138,6 @@ impl AccessControl {
}
/// Log a query for audit.
#[allow(dead_code)]
pub async fn log_query(&self, audit: QueryAudit) {
self.audit_log.write().await.push(audit);
}
@@ -151,9 +149,6 @@ impl AccessControl {
log[start..].iter().rev().cloned().collect()
}
/// Reports whether access-control enforcement is active.
/// Called by `GET /access/enabled` — ops tooling / dashboards poll
/// this to confirm the auth posture of the running gateway.
pub fn is_enabled(&self) -> bool {
self.enabled
}
+1 -21
View File
@@ -1,6 +1,6 @@
use axum::{
Json, Router,
extract::{Path, Query, State},
extract::{Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
@@ -13,12 +13,6 @@ pub fn router(ac: AccessControl) -> Router {
Router::new()
.route("/roles", get(list_roles))
.route("/roles", post(set_role))
// Scrum iter 11 / P13-001 finding: get_role was #[allow(dead_code)]
// because nothing called it — dead until exposed. Route activates it.
// Returns 404 when the agent isn't registered so clients can
// distinguish "missing role" from "access denied."
.route("/roles/{agent}", get(get_role))
.route("/enabled", get(enabled_status))
.route("/audit", get(query_audit))
.route("/check", post(check_access))
.with_state(ac)
@@ -66,17 +60,3 @@ async fn check_access(
"allowed": allowed,
}))
}
async fn get_role(
State(ac): State<AccessControl>,
Path(agent): Path<String>,
) -> impl IntoResponse {
match ac.get_role(&agent).await {
Some(role) => Ok(Json(role)),
None => Err((StatusCode::NOT_FOUND, format!("no role registered for agent '{agent}'"))),
}
}
async fn enabled_status(State(ac): State<AccessControl>) -> impl IntoResponse {
Json(serde_json::json!({ "enabled": ac.is_enabled() }))
}
+15 -36
View File
@@ -5,51 +5,30 @@ use axum::{
response::Response,
};
// API key auth middleware. Checks X-API-Key header against configured key.
// Fixed P5-001 (2026-04-23): previously #[allow(dead_code)] — the function
// existed but was never layered onto the router, so [auth] enabled=true
// silently enforced nothing. Now wired via from_fn_with_state in main.rs.
/// API key auth middleware. Checks X-API-Key header against configured key.
pub async fn api_key_auth(
axum::extract::State(expected): axum::extract::State<ApiKey>,
request: Request,
next: Next,
) -> Result<Response, StatusCode> {
// /health stays public (LB/systemd probes). Every other route is gated.
if request.uri().path() == "/health" {
return Ok(next.run(request).await);
}
// Get the expected key from the request extensions (set by the layer)
let expected_key = request.extensions().get::<ApiKey>().cloned();
let provided = request
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok());
if let Some(expected) = expected_key {
let provided = request
.headers()
.get("x-api-key")
.and_then(|v| v.to_str().ok());
// Constant-time-ish eq on the raw bytes; good enough for a shared-secret
// X-API-Key. Timing-attack resistance here matters less than the
// equivalent HMAC check would; adopt subtle crate if key-space grows.
match provided {
Some(key) if eq_ct(key.as_bytes(), expected.0.as_bytes()) => {
Ok(next.run(request).await)
}
_ => {
tracing::warn!(
path = %request.uri().path(),
"unauthorized request: missing or invalid API key",
);
Err(StatusCode::UNAUTHORIZED)
match provided {
Some(key) if key == expected.0 => {}
_ => {
tracing::warn!("unauthorized request: missing or invalid API key");
return Err(StatusCode::UNAUTHORIZED);
}
}
}
}
fn eq_ct(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
Ok(next.run(request).await)
}
/// Wrapper type for the API key, stored in request extensions.
@@ -1,388 +0,0 @@
//! KB context loader — reads recent signal from `data/_kb/*.jsonl` for
//! a given sig_hash + task_class and returns a compact summary.
//!
//! This is the "pipe to the overviewer" from the 2026-04-23 session:
//! the overseer tier (T3, gpt-oss:120b) consumes this context before
//! generating a correction, so its suggestions are informed by
//! historical cost / latency / outcome / prior-correction patterns
//! across ALL profiles that have run this task class — not just the
//! single current loop.
//!
//! Hot-swap profiles read the SAME pool. When a profile activates and
//! starts iterating, its KB context is the shared surface — one
//! profile's learning becomes every profile's starting point.
//!
//! Best-effort throughout: missing files, corrupt rows, empty
//! directories all produce an empty KbContext. The overseer works
//! fine with no history; we just can't seed it then.
use serde::Serialize;
use std::path::Path;
use tokio::io::AsyncBufReadExt;
/// Compact summary returned to the overseer. Bounded size — recent
/// outcomes + corrections plus rolled-up rates. Goal is to fit in a
/// prompt without eating the overseer's context budget.
#[derive(Debug, Clone, Default, Serialize)]
pub struct KbContext {
pub sig_hash: String,
pub task_class: String,
pub recent_outcomes: Vec<OutcomeSummary>,
pub recent_corrections: Vec<CorrectionSummary>,
pub success_rate: Option<f64>,
pub avg_turns: Option<f64>,
pub avg_latency_ms: Option<u64>,
pub total_observed: u32,
}
#[derive(Debug, Clone, Serialize)]
pub struct OutcomeSummary {
pub created_at: String,
pub ok: bool,
pub polarity: String,
pub turns: u32,
pub latency_ms: u64,
pub total_tokens: u64,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CorrectionSummary {
pub created_at: String,
pub reason: String,
pub correction_preview: String, // first 300 chars
pub applied_at_turn: u32,
}
const OUTCOMES_PATH: &str = "data/_kb/outcomes.jsonl";
const CORRECTIONS_PATH: &str = "data/_kb/overseer_corrections.jsonl";
const RECENT_OUTCOME_LIMIT: usize = 5;
const RECENT_CORRECTION_LIMIT: usize = 3;
const AGGREGATE_WINDOW: usize = 50;
impl KbContext {
/// Build context from the default KB paths.
pub async fn load_for(sig_hash: &str, task_class: &str) -> Self {
Self::load_from(
sig_hash, task_class,
Path::new(OUTCOMES_PATH), Path::new(CORRECTIONS_PATH),
).await
}
/// Path-taking variant — tests inject tmp files without touching
/// the real KB directory (same pattern as append_outcomes_row_at).
pub async fn load_from(
sig_hash: &str,
task_class: &str,
outcomes_path: &Path,
corrections_path: &Path,
) -> Self {
let mut ctx = KbContext {
sig_hash: sig_hash.to_string(),
task_class: task_class.to_string(),
..Default::default()
};
// Scan outcomes — matches on sig_hash primary, task_class
// secondary (so different geos for the same task_class still
// contribute to aggregate rates even though they won't make
// the top-5 recent). The bounded window keeps scan cost
// linear in file size — we're reading tail only.
let outcome_rows = tail_matching(
outcomes_path, AGGREGATE_WINDOW * 4,
|row| {
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
let row_tc = row.get("task_class").and_then(|v| v.as_str()).unwrap_or("");
row_sig == sig_hash || row_tc == task_class
},
).await;
// Recent outcomes: exact sig_hash match first (strongest
// signal), then task_class fallback up to the limit.
let mut exact: Vec<OutcomeSummary> = Vec::new();
let mut loose: Vec<OutcomeSummary> = Vec::new();
for row in &outcome_rows {
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
let summary = summarize_outcome(row);
if row_sig == sig_hash { exact.push(summary); }
else { loose.push(summary); }
}
ctx.recent_outcomes = exact.into_iter().rev().take(RECENT_OUTCOME_LIMIT).collect();
if ctx.recent_outcomes.len() < RECENT_OUTCOME_LIMIT {
let need = RECENT_OUTCOME_LIMIT - ctx.recent_outcomes.len();
ctx.recent_outcomes.extend(loose.into_iter().rev().take(need));
}
// Aggregate rates across the full matched window (both
// sig_hash and task_class matches — gives a stable rate even
// on sparse sig_hash history).
let window = outcome_rows.iter().rev().take(AGGREGATE_WINDOW);
let mut ok_count = 0u32;
let mut total = 0u32;
let mut turn_sum = 0u32;
let mut latency_sum = 0u64;
for row in window {
total += 1;
if row.get("ok").and_then(|v| v.as_bool()).unwrap_or(false) { ok_count += 1; }
turn_sum += row.get("turns").and_then(|v| v.as_u64()).unwrap_or(0) as u32;
latency_sum += row.get("usage")
.and_then(|u| u.get("latency_ms"))
.and_then(|v| v.as_u64()).unwrap_or(0);
}
if total > 0 {
ctx.total_observed = total;
ctx.success_rate = Some(ok_count as f64 / total as f64);
ctx.avg_turns = Some(turn_sum as f64 / total as f64);
ctx.avg_latency_ms = Some(latency_sum / total as u64);
}
// Overseer corrections. Prefer sig_hash match; fall back to
// task_class. The overseer reading its OWN prior corrections
// is the main point — if the last 3 attempts produced
// corrections X, Y, Z, the new correction should acknowledge
// those patterns rather than suggest X for the fourth time.
let correction_rows = tail_matching(
corrections_path, RECENT_CORRECTION_LIMIT * 4,
|row| {
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
let row_tc = row.get("task_class").and_then(|v| v.as_str()).unwrap_or("");
row_sig == sig_hash || row_tc == task_class
},
).await;
let mut c_exact: Vec<CorrectionSummary> = Vec::new();
let mut c_loose: Vec<CorrectionSummary> = Vec::new();
for row in &correction_rows {
let row_sig = row.get("sig_hash").and_then(|v| v.as_str()).unwrap_or("");
let summary = summarize_correction(row);
if row_sig == sig_hash { c_exact.push(summary); }
else { c_loose.push(summary); }
}
ctx.recent_corrections = c_exact.into_iter().rev().take(RECENT_CORRECTION_LIMIT).collect();
if ctx.recent_corrections.len() < RECENT_CORRECTION_LIMIT {
let need = RECENT_CORRECTION_LIMIT - ctx.recent_corrections.len();
ctx.recent_corrections.extend(c_loose.into_iter().rev().take(need));
}
ctx
}
/// Compact string form for the overseer prompt. Deterministic
/// ordering + bounded length so prompt caching stays stable
/// across iterations on the same task.
pub fn to_prompt_section(&self) -> String {
let mut s = String::new();
s.push_str("## Knowledge Base Context\n");
if let (Some(rate), Some(turns), Some(lat)) = (self.success_rate, self.avg_turns, self.avg_latency_ms) {
s.push_str(&format!(
"Across {} prior similar runs: success_rate={:.1}%, avg_turns={:.1}, avg_latency_ms={}\n",
self.total_observed, rate * 100.0, turns, lat,
));
} else {
s.push_str("No prior similar runs recorded.\n");
}
if !self.recent_outcomes.is_empty() {
s.push_str(&format!("\nRecent {} outcomes:\n", self.recent_outcomes.len()));
for o in &self.recent_outcomes {
let err = o.error.as_deref().map(|e| format!(" — {}", truncate(e, 80))).unwrap_or_default();
s.push_str(&format!(
" [{}] ok={} turns={} tokens={} lat={}ms{}\n",
&o.created_at[..19.min(o.created_at.len())],
o.ok, o.turns, o.total_tokens, o.latency_ms, err,
));
}
}
if !self.recent_corrections.is_empty() {
s.push_str(&format!("\nRecent {} overseer corrections (yours — don't repeat):\n", self.recent_corrections.len()));
for c in &self.recent_corrections {
s.push_str(&format!(
" [{}] turn={} reason={} correction={}\n",
&c.created_at[..19.min(c.created_at.len())],
c.applied_at_turn,
truncate(&c.reason, 40),
truncate(&c.correction_preview, 200),
));
}
}
s
}
}
fn summarize_outcome(row: &serde_json::Value) -> OutcomeSummary {
OutcomeSummary {
created_at: row.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string(),
ok: row.get("ok").and_then(|v| v.as_bool()).unwrap_or(false),
polarity: row.get("polarity").and_then(|v| v.as_str()).unwrap_or("").to_string(),
turns: row.get("turns").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
latency_ms: row.get("usage").and_then(|u| u.get("latency_ms"))
.and_then(|v| v.as_u64()).unwrap_or(0),
total_tokens: row.get("usage").and_then(|u| u.get("total_tokens"))
.and_then(|v| v.as_u64()).unwrap_or(0),
error: row.get("error").and_then(|v| v.as_str()).map(String::from),
}
}
fn summarize_correction(row: &serde_json::Value) -> CorrectionSummary {
let preview = row.get("correction").and_then(|v| v.as_str()).unwrap_or("");
CorrectionSummary {
created_at: row.get("created_at").and_then(|v| v.as_str()).unwrap_or("").to_string(),
reason: row.get("reason").and_then(|v| v.as_str()).unwrap_or("").to_string(),
correction_preview: truncate(preview, 300),
applied_at_turn: row.get("applied_at_turn").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
}
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n { s.to_string() } else { format!("{}…", &s[..n]) }
}
/// Read a JSONL file from the tail, returning at most `limit` rows
/// that match `filter`. Missing file returns empty. Corrupt lines are
/// skipped. Limit is honored from the tail — a full-file scan with an
/// in-memory ring would be wasteful for large outcomes histories, but
/// we cap at reading the whole file and filtering post-hoc for now
/// (reverse-seek line iteration is a real engineering task and the
/// file is bounded by ingest rate; revisit when it bites).
async fn tail_matching<F>(
path: &Path,
limit: usize,
filter: F,
) -> Vec<serde_json::Value>
where
F: Fn(&serde_json::Value) -> bool,
{
let Ok(file) = tokio::fs::File::open(path).await else { return Vec::new(); };
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut matches: Vec<serde_json::Value> = Vec::new();
while let Ok(Some(line)) = lines.next_line().await {
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else { continue };
if filter(&v) {
matches.push(v);
if matches.len() > limit {
// Keep the most-recent window only — drop from the
// front as we go rather than buffering everything.
matches.remove(0);
}
}
}
matches
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::io::AsyncWriteExt;
async fn write_fixture(path: &Path, rows: Vec<serde_json::Value>) {
if let Some(dir) = path.parent() {
tokio::fs::create_dir_all(dir).await.unwrap();
}
let mut f = tokio::fs::OpenOptions::new()
.create(true).write(true).truncate(true).open(path).await.unwrap();
for r in rows {
let mut line = serde_json::to_string(&r).unwrap();
line.push('\n');
f.write_all(line.as_bytes()).await.unwrap();
}
}
fn tmp_path(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
std::env::temp_dir().join(format!("lh_kb_ctx_{}_{}_{}", std::process::id(), nanos, name))
}
#[tokio::test]
async fn empty_files_produce_empty_context() {
let op = tmp_path("outcomes.jsonl");
let cp = tmp_path("corrections.jsonl");
let ctx = KbContext::load_from("sig123", "staffing.fill", &op, &cp).await;
assert!(ctx.recent_outcomes.is_empty());
assert!(ctx.recent_corrections.is_empty());
assert!(ctx.success_rate.is_none());
assert_eq!(ctx.total_observed, 0);
}
#[tokio::test]
async fn exact_sig_hash_matches_take_priority() {
let op = tmp_path("outcomes.jsonl");
let cp = tmp_path("corrections.jsonl");
write_fixture(&op, vec![
// Other sig_hash, same task_class — loose match
serde_json::json!({
"sig_hash": "other", "task_class": "staffing.fill",
"ok": false, "polarity": "failure_pattern", "turns": 1,
"usage": {"latency_ms": 1000, "total_tokens": 100},
"created_at": "2026-04-22T10:00:00Z",
}),
// Exact sig_hash — should lead
serde_json::json!({
"sig_hash": "sig123", "task_class": "staffing.fill",
"ok": true, "polarity": "success_confirmation", "turns": 3,
"usage": {"latency_ms": 2000, "total_tokens": 500},
"created_at": "2026-04-23T10:00:00Z",
}),
]).await;
write_fixture(&cp, vec![]).await;
let ctx = KbContext::load_from("sig123", "staffing.fill", &op, &cp).await;
assert_eq!(ctx.recent_outcomes.len(), 2);
assert_eq!(ctx.recent_outcomes[0].created_at, "2026-04-23T10:00:00Z");
assert_eq!(ctx.recent_outcomes[0].ok, true);
assert_eq!(ctx.total_observed, 2);
assert!((ctx.success_rate.unwrap() - 0.5).abs() < 0.001);
}
#[tokio::test]
async fn corrupt_rows_are_skipped() {
let op = tmp_path("outcomes.jsonl");
let cp = tmp_path("corrections.jsonl");
// Mix valid + invalid — invalid should be silently skipped.
if let Some(dir) = op.parent() { tokio::fs::create_dir_all(dir).await.unwrap(); }
tokio::fs::write(&op, "not json\n{\"sig_hash\":\"sig1\",\"task_class\":\"tc\",\"ok\":true,\"turns\":1,\"usage\":{}}\ngarbage\n").await.unwrap();
write_fixture(&cp, vec![]).await;
let ctx = KbContext::load_from("sig1", "tc", &op, &cp).await;
assert_eq!(ctx.recent_outcomes.len(), 1);
}
#[tokio::test]
async fn corrections_preview_is_truncated() {
let op = tmp_path("outcomes.jsonl");
let cp = tmp_path("corrections.jsonl");
let long = "x".repeat(500);
write_fixture(&op, vec![]).await;
write_fixture(&cp, vec![serde_json::json!({
"sig_hash": "sig1", "task_class": "tc",
"reason": "abort", "correction": long, "applied_at_turn": 3,
"created_at": "2026-04-23T10:00:00Z",
})]).await;
let ctx = KbContext::load_from("sig1", "tc", &op, &cp).await;
assert_eq!(ctx.recent_corrections.len(), 1);
// 300-char cap + 3-byte UTF-8 ellipsis character = 303-byte worst case.
assert!(ctx.recent_corrections[0].correction_preview.len() <= 303);
}
#[test]
fn prompt_section_is_stable_for_empty_context() {
let ctx = KbContext::default();
let s = ctx.to_prompt_section();
assert!(s.contains("No prior similar runs recorded"));
}
#[test]
fn prompt_section_reports_aggregate_rates() {
let ctx = KbContext {
total_observed: 10,
success_rate: Some(0.7),
avg_turns: Some(4.2),
avg_latency_ms: Some(45000),
..Default::default()
};
let s = ctx.to_prompt_section();
assert!(s.contains("success_rate=70.0%"));
assert!(s.contains("avg_turns=4.2"));
assert!(s.contains("avg_latency_ms=45000"));
}
}
File diff suppressed because it is too large Load Diff
+6 -78
View File
@@ -1,7 +1,6 @@
mod access;
mod access_service;
mod auth;
mod execution_loop;
mod observability;
mod tools;
mod v1;
@@ -68,27 +67,6 @@ async fn main() {
let access = access::AccessControl::new(config.auth.enabled);
access.register_defaults().await;
// Phase 42 — file-backed truth rules. Probes the `truth/` directory
// at repo root (or $LAKEHOUSE_TRUTH_DIR override) and logs how many
// rules load. Current request paths still build their own stores
// via truth::default_truth_store() / truth::sql_query_guard_store();
// the composed-at-boot store gets plumbed through V1State in a
// follow-up. This boot probe catches parse errors + duplicate-ID
// collisions early rather than at first request.
{
let truth_dir = std::env::var("LAKEHOUSE_TRUTH_DIR")
.unwrap_or_else(|_| "/home/profit/lakehouse/truth".to_string());
if std::path::Path::new(&truth_dir).exists() {
let mut probe_store = truth::default_truth_store();
match truth::loader::load_from_dir(&mut probe_store, &truth_dir) {
Ok(n) => tracing::info!("truth: loaded {n} file-backed rule(s) from {truth_dir}"),
Err(e) => tracing::warn!("truth: failed to load rules from {truth_dir}: {e}"),
}
} else {
tracing::debug!("truth: no rule dir at {truth_dir}, skipping file-backed load");
}
}
// Workspace manager for agent-specific overlays
let workspace_mgr = queryd::workspace::WorkspaceManager::new(store.clone());
if let Err(e) = workspace_mgr.rebuild().await {
@@ -114,12 +92,6 @@ async fn main() {
// operators call POST /vectors/playbook_memory/rebuild to populate.
let pbm = vectord::playbook_memory::PlaybookMemory::new(store.clone());
let _ = pbm.load_from_storage().await;
// Pathway memory — consensus-designed sidecar for full-context
// backtracking + hot-swap of successful review pathways. Same
// load-on-boot pattern as playbook_memory: empty state is fine,
// operators start populating via scrum_master_pipeline.ts.
let pwm = vectord::pathway_memory::PathwayMemory::new(store.clone());
let _ = pwm.load_from_storage().await;
// Phase 16.2: spawn the autotune agent. When config.agent.enabled=false
// this returns a handle that drops triggers silently — no surprise load.
@@ -181,11 +153,6 @@ async fn main() {
agent_handle: agent_handle.clone(),
index_registry: index_reg.clone(),
schedules: sched_store,
// P9-001 fix 2026-04-23: journal reference flows into ingest so
// successful uploads emit a record_ingest event. Journal is Clone
// (Arc<RwLock> inside) so the /journal nest below still sees the
// same buffer + persistence.
journal: Some(journal.clone()),
}))
.nest("/vectors", vectord::service::router(vectord::service::VectorState {
store: store.clone(),
@@ -205,19 +172,17 @@ async fn main() {
bucket_registry.clone(), index_reg.clone(),
),
playbook_memory: pbm,
pathway_memory: pwm,
embed_semaphore: std::sync::Arc::new(tokio::sync::Semaphore::new(1)),
}))
.nest("/workspaces", queryd::workspace_service::router(workspace_mgr))
.nest("/journal", journald::service::router(journal))
.nest("/access", access_service::router(access))
.nest("/tools", tools::service::router({
let tool_reg = tools::registry::ToolRegistry::new();
let tool_reg = tools::registry::ToolRegistry::new_with_defaults();
tool_reg.register_defaults().await;
tools::ToolState {
registry: tool_reg,
query_fn: tools::QueryExecutor::new(engine.clone()),
truth: std::sync::Arc::new(truth::sql_query_guard_store()),
}
}))
// Phase 38 — Universal API skeleton. Thin OpenAI-compatible
@@ -239,38 +204,6 @@ async fn main() {
}
k
},
openrouter_key: {
// 2026-04-24 free-tier rescue rung for iter 5+. Shares
// the LLM Team UI's OPENROUTER_API_KEY so both systems
// draw from one quota.
let k = v1::openrouter::resolve_openrouter_key();
if k.is_some() {
tracing::info!("v1: OpenRouter key loaded — /v1/chat provider=openrouter enabled");
} else {
tracing::warn!("v1: no OpenRouter key — openrouter rescue rung will 503");
}
k
},
gemini_key: {
// Phase 40 provider. GEMINI_API_KEY in env or .env.
let k = v1::gemini::resolve_gemini_key();
if k.is_some() {
tracing::info!("v1: Gemini key loaded — /v1/chat provider=gemini enabled");
} else {
tracing::debug!("v1: no Gemini key — provider=gemini will 503");
}
k
},
claude_key: {
// Phase 40 provider. ANTHROPIC_API_KEY in env or .env.
let k = v1::claude::resolve_claude_key();
if k.is_some() {
tracing::info!("v1: Claude key loaded — /v1/chat provider=claude enabled");
} else {
tracing::debug!("v1: no Claude key — provider=claude will 503");
}
k
},
// Phase 40 early deliverable — Langfuse trace emitter.
// Defaults match mcp-server/tracing.ts conventions so
// gateway traces land in the same staffing project.
@@ -285,19 +218,14 @@ async fn main() {
},
}));
// Auth middleware (if enabled) — P5-001 fix 2026-04-23:
// previously only inserted the ApiKey as an extension and never layered
// the middleware, so auth.enabled=true enforced nothing. Now wraps the
// router with from_fn_with_state, which calls api_key_auth on every
// request. /health is exempted inside the middleware (LB probes).
// Auth middleware (if enabled)
if config.auth.enabled {
if let Some(ref key) = config.auth.api_key {
tracing::info!("API key auth enabled — enforcing on all routes except /health");
tracing::info!("API key auth enabled");
let api_key = auth::ApiKey(key.clone());
app = app.layer(axum::middleware::from_fn_with_state(
api_key,
auth::api_key_auth,
));
app = app.layer(axum::Extension(api_key));
// Note: auth middleware applied per-route in production
// For now, the ApiKey extension is available for handlers to check
} else {
tracing::warn!("auth enabled but no api_key set — all requests allowed");
}
-6
View File
@@ -3,18 +3,12 @@ pub mod service;
use queryd::context::QueryEngine;
use arrow::json::writer::{JsonArray, Writer as JsonWriter};
use std::sync::Arc;
use truth::TruthStore;
/// State for the tool system.
#[derive(Clone)]
pub struct ToolState {
pub registry: registry::ToolRegistry,
pub query_fn: QueryExecutor,
/// SQL guard (shared with queryd). Mirrors the queryd /sql truth
/// gate from P42-002 (9cc0ceb) — tools also execute model-
/// originated SQL, need the same destructive-verb block.
pub truth: Arc<TruthStore>,
}
/// Wraps QueryEngine to provide a simple execute interface for tools.
+15 -5
View File
@@ -67,14 +67,24 @@ pub struct ToolRegistry {
}
impl ToolRegistry {
/// Build an empty registry. Callers in an async context should follow
/// this with `.register_defaults().await` if they want the built-in
/// staffing tools pre-installed — main.rs does exactly that.
pub fn new() -> Self {
Self {
let registry = Self {
tools: Arc::new(RwLock::new(HashMap::new())),
audit_log: Arc::new(RwLock::new(Vec::new())),
}
};
// Register built-in staffing tools
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(registry.register_defaults())
});
registry
}
pub fn new_with_defaults() -> Self {
let registry = Self {
tools: Arc::new(RwLock::new(HashMap::new())),
audit_log: Arc::new(RwLock::new(Vec::new())),
};
registry
}
/// Register default staffing tools.
+1 -27
View File
@@ -7,7 +7,7 @@ use axum::{
};
use serde::Deserialize;
use super::registry::{ToolInvocation, ToolRegistry};
use super::registry::{Permission, ToolInvocation, ToolRegistry};
use crate::tools::ToolState;
pub fn router(state: ToolState) -> Router {
@@ -92,32 +92,6 @@ async fn call_tool(
}
};
// Truth gate — same contract as queryd /sql (P42-002). Rejects
// destructive verbs + empty SQL. Scrum iter 11 CF-1 + CF-2 on this
// file: tools executed model-provided SQL parameters without any
// validation. Close the gap here so the parallel surface has the
// same safety floor as queryd.
let ctx = serde_json::json!({ "sql": sql });
for outcome in state.truth.evaluate("sql_query", &ctx) {
if outcome.passed {
if let truth::RuleAction::Reject { message } | truth::RuleAction::Block { message } = &outcome.action {
tracing::warn!("tool {name}: SQL blocked by truth gate ({}): {message}", outcome.rule_id);
state.registry.log_invocation(ToolInvocation {
id: format!("inv-{}", chrono::Utc::now().timestamp_millis()),
tool_name: name.clone(),
agent: req.agent.clone(),
params: req.params.clone(),
permission: tool.permission.clone(),
timestamp: chrono::Utc::now(),
success: false,
error: Some(format!("truth gate: {message}")),
rows_returned: None,
}).await;
return Err((StatusCode::FORBIDDEN, message.clone()));
}
}
}
// Execute via query engine
let result = state.query_fn.execute(&sql).await;
-222
View File
@@ -1,222 +0,0 @@
//! Claude (Anthropic) adapter.
//!
//! POST `https://api.anthropic.com/v1/messages`. Auth via `x-api-key`
//! header (not bearer) + required `anthropic-version` header. Payload
//! is NOT OpenAI-compatible — response text lives at
//! `content[0].text`. Phase 40 deliverable. System prompts travel in
//! a top-level `system` field, separate from the `messages` array.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
const CLAUDE_BASE_URL: &str = "https://api.anthropic.com/v1";
const CLAUDE_API_VERSION: &str = "2023-06-01";
const CLAUDE_TIMEOUT_SECS: u64 = 180;
pub fn resolve_claude_key() -> Option<String> {
if let Ok(k) = std::env::var("ANTHROPIC_API_KEY") {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
for path in ["/home/profit/.env", "/root/.env"] {
if let Ok(raw) = std::fs::read_to_string(path) {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("ANTHROPIC_API_KEY=") {
let k = rest.trim().trim_matches('"').trim_matches('\'');
if !k.is_empty() { return Some(k.to_string()); }
}
}
}
}
None
}
pub async fn chat(
key: &str,
req: &ChatRequest,
) -> Result<ChatResponse, String> {
// Strip the "claude/" prefix if the caller used the namespaced form.
let model = req.model.strip_prefix("claude/").unwrap_or(&req.model).to_string();
// Anthropic carries system prompts outside the messages array.
// Concatenate any system-role messages into a single system string;
// keep user + assistant messages in `messages`.
let mut system_parts: Vec<String> = Vec::new();
let mut msgs: Vec<AnMessage> = Vec::new();
for m in &req.messages {
if m.role == "system" {
system_parts.push(m.content.clone());
} else {
// Anthropic expects strictly "user" or "assistant"; anything
// else we normalize to "user".
let role = if m.role == "assistant" { "assistant" } else { "user" };
msgs.push(AnMessage { role: role.to_string(), content: m.content.clone() });
}
}
let system = if system_parts.is_empty() {
None
} else {
Some(system_parts.join("\n\n"))
};
let body = AnChatBody {
model: model.clone(),
messages: msgs,
max_tokens: req.max_tokens.unwrap_or(800),
temperature: req.temperature.unwrap_or(0.3),
system,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(CLAUDE_TIMEOUT_SECS))
.build()
.map_err(|e| format!("build client: {e}"))?;
let t0 = std::time::Instant::now();
let resp = client
.post(format!("{}/messages", CLAUDE_BASE_URL))
.header("x-api-key", key)
.header("anthropic-version", CLAUDE_API_VERSION)
.json(&body)
.send()
.await
.map_err(|e| format!("api.anthropic.com unreachable: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_else(|_| "?".into());
return Err(format!("claude {}: {}", status, body));
}
let parsed: AnChatResponse = resp.json().await
.map_err(|e| format!("invalid claude response: {e}"))?;
let latency_ms = t0.elapsed().as_millis();
let text = parsed.content.into_iter()
.find(|b| b.block_type == "text")
.map(|b| b.text)
.unwrap_or_default();
let prompt_tokens = parsed.usage.as_ref().map(|u| u.input_tokens).unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.content.chars().count()).sum();
((chars + 3) / 4) as u32
});
let completion_tokens = parsed.usage.as_ref().map(|u| u.output_tokens).unwrap_or_else(|| {
((text.chars().count() + 3) / 4) as u32
});
tracing::info!(
target: "v1.chat",
provider = "claude",
model = %model,
prompt_tokens,
completion_tokens,
latency_ms = latency_ms as u64,
"claude chat completed",
);
Ok(ChatResponse {
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
object: "chat.completion",
created: chrono::Utc::now().timestamp(),
model,
choices: vec![Choice {
index: 0,
message: Message { role: "assistant".into(), content: text },
finish_reason: parsed.stop_reason.unwrap_or_else(|| "stop".into()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
})
}
// -- Anthropic Messages API wire shapes --
#[derive(Serialize)]
struct AnChatBody {
model: String,
messages: Vec<AnMessage>,
max_tokens: u32,
temperature: f64,
#[serde(skip_serializing_if = "Option::is_none")]
system: Option<String>,
}
#[derive(Serialize)]
struct AnMessage { role: String, content: String }
#[derive(Deserialize)]
struct AnChatResponse {
content: Vec<AnContentBlock>,
#[serde(default, rename = "stop_reason")]
stop_reason: Option<String>,
#[serde(default)]
usage: Option<AnUsage>,
}
#[derive(Deserialize)]
struct AnContentBlock {
#[serde(rename = "type")]
block_type: String,
#[serde(default)]
text: String,
}
#[derive(Deserialize)]
struct AnUsage { input_tokens: u32, output_tokens: u32 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_claude_key_does_not_panic() {
let _ = resolve_claude_key();
}
#[test]
fn chat_body_serializes_with_separate_system() {
let body = AnChatBody {
model: "claude-3-5-sonnet-latest".into(),
messages: vec![
AnMessage { role: "user".into(), content: "hi".into() },
],
max_tokens: 800,
temperature: 0.3,
system: Some("You are helpful.".into()),
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"system\":\"You are helpful.\""));
assert!(json.contains("\"messages\""));
assert!(json.contains("\"max_tokens\":800"));
}
#[test]
fn body_omits_system_when_none() {
let body = AnChatBody {
model: "claude-3-5-sonnet-latest".into(),
messages: vec![AnMessage { role: "user".into(), content: "hi".into() }],
max_tokens: 800,
temperature: 0.3,
system: None,
};
let json = serde_json::to_string(&body).unwrap();
assert!(!json.contains("\"system\""), "system field should be skipped when None: {json}");
}
#[test]
fn model_prefix_strip_preserves_bare_names() {
let cases = [
("claude/claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"),
("claude-3-5-sonnet-latest", "claude-3-5-sonnet-latest"),
];
for (input, expected) in cases {
let out = input.strip_prefix("claude/").unwrap_or(input);
assert_eq!(out, expected);
}
}
}
-230
View File
@@ -1,230 +0,0 @@
//! Gemini adapter — Google's Generative Language API.
//!
//! POST `https://generativelanguage.googleapis.com/v1beta/models/
//! {model}:generateContent?key=<API_KEY>`. Auth via query-string key
//! (not bearer). Payload shape is NOT OpenAI-compatible — we map
//! messages → contents + parts, extract response from `candidates[0]
//! .content.parts[0].text`. Phase 40 deliverable; gate: `/v1/chat`
//! with a prefixed or explicit gemini model returns normally.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
const GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
const GEMINI_TIMEOUT_SECS: u64 = 180;
pub fn resolve_gemini_key() -> Option<String> {
if let Ok(k) = std::env::var("GEMINI_API_KEY") {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
for path in ["/home/profit/.env", "/root/.env"] {
if let Ok(raw) = std::fs::read_to_string(path) {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("GEMINI_API_KEY=") {
let k = rest.trim().trim_matches('"').trim_matches('\'');
if !k.is_empty() { return Some(k.to_string()); }
}
}
}
}
None
}
pub async fn chat(
key: &str,
req: &ChatRequest,
) -> Result<ChatResponse, String> {
// Strip the "gemini/" prefix if the caller used the namespaced form.
let model = req.model.strip_prefix("gemini/").unwrap_or(&req.model).to_string();
// Gemini splits system prompt from conversation differently.
// Simplest working mapping: concatenate any system messages at the
// top of a single user turn, then append user/assistant turns as
// separate contents entries. Covers the common single-turn case
// the scrum pipeline uses.
let mut contents: Vec<GmContent> = Vec::new();
for m in &req.messages {
let role = match m.role.as_str() {
"system" | "user" => "user",
_ => "model",
};
contents.push(GmContent {
role: role.to_string(),
parts: vec![GmPart { text: m.content.clone() }],
});
}
let body = GmChatBody {
contents,
generation_config: GmGenerationConfig {
temperature: req.temperature.unwrap_or(0.3),
max_output_tokens: req.max_tokens.unwrap_or(800),
},
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(GEMINI_TIMEOUT_SECS))
.build()
.map_err(|e| format!("build client: {e}"))?;
let url = format!("{}/models/{}:generateContent?key={}", GEMINI_BASE_URL, model, key);
let t0 = std::time::Instant::now();
let resp = client
.post(&url)
.json(&body)
.send()
.await
.map_err(|e| format!("generativelanguage.googleapis.com unreachable: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_else(|_| "?".into());
return Err(format!("gemini {}: {}", status, body));
}
let parsed: GmChatResponse = resp.json().await
.map_err(|e| format!("invalid gemini response: {e}"))?;
let latency_ms = t0.elapsed().as_millis();
let candidate = parsed.candidates.into_iter().next()
.ok_or_else(|| "gemini returned no candidates".to_string())?;
let text = candidate.content.parts.into_iter()
.next()
.map(|p| p.text)
.unwrap_or_default();
let prompt_tokens = parsed.usage_metadata.as_ref()
.map(|u| u.prompt_token_count)
.unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.content.chars().count()).sum();
((chars + 3) / 4) as u32
});
let completion_tokens = parsed.usage_metadata.as_ref()
.map(|u| u.candidates_token_count)
.unwrap_or_else(|| ((text.chars().count() + 3) / 4) as u32);
tracing::info!(
target: "v1.chat",
provider = "gemini",
model = %model,
prompt_tokens,
completion_tokens,
latency_ms = latency_ms as u64,
"gemini chat completed",
);
Ok(ChatResponse {
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
object: "chat.completion",
created: chrono::Utc::now().timestamp(),
model,
choices: vec![Choice {
index: 0,
message: Message { role: "assistant".into(), content: text },
finish_reason: candidate.finish_reason.unwrap_or_else(|| "stop".into()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
})
}
// -- Gemini wire shapes --
#[derive(Serialize)]
struct GmChatBody {
contents: Vec<GmContent>,
#[serde(rename = "generationConfig")]
generation_config: GmGenerationConfig,
}
#[derive(Serialize)]
struct GmContent {
role: String,
parts: Vec<GmPart>,
}
#[derive(Serialize)]
struct GmPart { text: String }
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GmGenerationConfig {
temperature: f64,
max_output_tokens: u32,
}
#[derive(Deserialize)]
struct GmChatResponse {
candidates: Vec<GmCandidate>,
#[serde(default, rename = "usageMetadata")]
usage_metadata: Option<GmUsage>,
}
#[derive(Deserialize)]
struct GmCandidate {
content: GmContentResp,
#[serde(default, rename = "finishReason")]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct GmContentResp { parts: Vec<GmPartResp> }
#[derive(Deserialize)]
struct GmPartResp { #[serde(default)] text: String }
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct GmUsage {
prompt_token_count: u32,
candidates_token_count: u32,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_gemini_key_does_not_panic() {
let _ = resolve_gemini_key();
}
#[test]
fn chat_body_serializes_to_gemini_shape() {
let body = GmChatBody {
contents: vec![
GmContent {
role: "user".into(),
parts: vec![GmPart { text: "hello".into() }],
},
],
generation_config: GmGenerationConfig {
temperature: 0.3,
max_output_tokens: 800,
},
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"contents\""));
assert!(json.contains("\"parts\""));
// camelCase per Gemini API
assert!(json.contains("\"generationConfig\""));
assert!(json.contains("\"maxOutputTokens\":800"));
}
#[test]
fn model_prefix_strip_preserves_bare_names() {
let cases = [
("gemini/gemini-2.0-flash", "gemini-2.0-flash"),
("gemini-2.0-flash", "gemini-2.0-flash"),
];
for (input, expected) in cases {
let out = input.strip_prefix("gemini/").unwrap_or(input);
assert_eq!(out, expected);
}
}
}
+11 -195
View File
@@ -13,12 +13,7 @@
pub mod ollama;
pub mod ollama_cloud;
pub mod openrouter;
pub mod gemini;
pub mod claude;
pub mod langfuse_trace;
pub mod respond;
pub mod truth;
use axum::{
Router,
@@ -39,19 +34,6 @@ pub struct V1State {
/// Ollama Cloud bearer token. Loaded at startup via
/// `ollama_cloud::resolve_cloud_key()`. None = cloud routes 503.
pub ollama_cloud_key: Option<String>,
/// OpenRouter bearer token — free-tier rescue rung. Loaded at
/// startup via `openrouter::resolve_openrouter_key()`. None means
/// provider="openrouter" calls 503 rather than attempt. Same key
/// sourcing as LLM Team UI so the two share one API quota.
pub openrouter_key: Option<String>,
/// Gemini API key (Google Generative Language). Loaded at startup
/// via `gemini::resolve_gemini_key()`. None = provider="gemini"
/// calls 503. Phase 40 deliverable.
pub gemini_key: Option<String>,
/// Anthropic Claude API key. Loaded at startup via
/// `claude::resolve_claude_key()`. None = provider="claude" calls
/// 503. Phase 40 deliverable.
pub claude_key: Option<String>,
/// Phase 40 early deliverable — Langfuse client. None = tracing
/// disabled (keys missing or container unreachable). Traces are
/// fire-and-forget: never block the response path.
@@ -64,25 +46,13 @@ pub struct Usage {
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
#[serde(default)]
pub by_provider: std::collections::HashMap<String, ProviderUsage>,
}
#[derive(Default, Clone, Serialize)]
pub struct ProviderUsage {
pub requests: u64,
pub prompt_tokens: u64,
pub completion_tokens: u64,
pub total_tokens: u64,
}
pub fn router(state: V1State) -> Router {
Router::new()
.route("/chat", post(chat))
.route("/respond", post(respond::respond))
.route("/usage", get(usage))
.route("/sessions", get(sessions))
.route("/context", get(truth::context))
.with_state(state)
}
@@ -94,7 +64,7 @@ pub struct Message {
pub content: String,
}
#[derive(Deserialize, Debug, Clone)]
#[derive(Deserialize, Debug)]
pub struct ChatRequest {
pub model: String,
pub messages: Vec<Message>,
@@ -150,96 +120,6 @@ pub struct UsageBlock {
// -- Handlers --
/// Phase 39: resolve (provider, effective_model) from a ChatRequest.
///
/// Explicit `req.provider` wins. If absent, infer from a model-name
/// prefix: "openrouter/..." → openrouter (strip prefix), "cloud/..." →
/// ollama_cloud (strip prefix). Bare names default to "ollama".
///
/// The stripped model is what the upstream adapter expects:
/// OpenRouter's API wants "openai/gpt-4o-mini", not
/// "openrouter/openai/gpt-4o-mini".
fn resolve_provider(req: &ChatRequest) -> (String, String) {
if let Some(p) = req.provider.as_deref() {
return (p.to_ascii_lowercase(), req.model.clone());
}
if let Some(rest) = req.model.strip_prefix("openrouter/") {
return ("openrouter".to_string(), rest.to_string());
}
if let Some(rest) = req.model.strip_prefix("cloud/") {
return ("ollama_cloud".to_string(), rest.to_string());
}
if let Some(rest) = req.model.strip_prefix("gemini/") {
return ("gemini".to_string(), rest.to_string());
}
if let Some(rest) = req.model.strip_prefix("claude/") {
return ("claude".to_string(), rest.to_string());
}
("ollama".to_string(), req.model.clone())
}
#[cfg(test)]
mod resolve_provider_tests {
use super::*;
fn mk_req(provider: Option<&str>, model: &str) -> ChatRequest {
ChatRequest {
model: model.to_string(),
messages: vec![],
temperature: None,
max_tokens: None,
stream: None,
think: None,
provider: provider.map(|s| s.to_string()),
}
}
#[test]
fn explicit_provider_wins() {
let r = mk_req(Some("openrouter"), "qwen3.5:latest");
assert_eq!(resolve_provider(&r), ("openrouter".into(), "qwen3.5:latest".into()));
}
#[test]
fn bare_model_defaults_to_ollama() {
let r = mk_req(None, "qwen3.5:latest");
assert_eq!(resolve_provider(&r), ("ollama".into(), "qwen3.5:latest".into()));
}
#[test]
fn openrouter_prefix_infers_and_strips() {
let r = mk_req(None, "openrouter/openai/gpt-4o-mini");
assert_eq!(resolve_provider(&r), ("openrouter".into(), "openai/gpt-4o-mini".into()));
}
#[test]
fn cloud_prefix_infers_and_strips() {
let r = mk_req(None, "cloud/kimi-k2:1t");
assert_eq!(resolve_provider(&r), ("ollama_cloud".into(), "kimi-k2:1t".into()));
}
#[test]
fn explicit_provider_preserves_full_model_even_with_prefix() {
// If caller provides both provider and a model with a prefix,
// trust them — don't strip. The adapter will get the full model
// string as-is.
let r = mk_req(Some("openrouter"), "openrouter/openai/gpt-4o-mini");
assert_eq!(resolve_provider(&r), ("openrouter".into(), "openrouter/openai/gpt-4o-mini".into()));
}
#[test]
fn gemini_prefix_infers_and_strips() {
let r = mk_req(None, "gemini/gemini-2.0-flash");
assert_eq!(resolve_provider(&r), ("gemini".into(), "gemini-2.0-flash".into()));
}
#[test]
fn claude_prefix_infers_and_strips() {
let r = mk_req(None, "claude/claude-3-5-sonnet-latest");
assert_eq!(resolve_provider(&r), ("claude".into(), "claude-3-5-sonnet-latest".into()));
}
}
async fn chat(
State(state): State<V1State>,
Json(req): Json<ChatRequest>,
@@ -251,84 +131,27 @@ async fn chat(
tracing::warn!("/v1/chat: stream=true requested but Phase 38 returns non-streaming");
}
// Provider resolution: explicit `req.provider` wins; otherwise
// infer from a model-name prefix. Phase 39 PRD gate example:
// `model: "openrouter/openai/gpt-4o-mini"` → provider "openrouter",
// adapter gets the stripped "openai/gpt-4o-mini".
let (provider, effective_model) = resolve_provider(&req);
let provider = req.provider.as_deref().unwrap_or("ollama").to_ascii_lowercase();
let start_time = chrono::Utc::now();
let start_instant = std::time::Instant::now();
// If we stripped a prefix, clone req with the effective model so
// the adapter sees what the upstream provider expects (OpenRouter
// wants "openai/gpt-4o-mini", not "openrouter/openai/gpt-4o-mini").
let req_for_adapter: std::borrow::Cow<'_, ChatRequest> =
if effective_model == req.model {
std::borrow::Cow::Borrowed(&req)
} else {
let mut cloned = req.clone();
cloned.model = effective_model.clone();
std::borrow::Cow::Owned(cloned)
};
let (resp, used_provider) = match provider.as_str() {
"ollama" | "local" | "" => {
let r = ollama::chat(&state.ai_client, &*req_for_adapter)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama local: {e}")))?;
(r, "ollama".to_string())
}
let resp = match provider.as_str() {
"ollama" | "local" | "" => ollama::chat(&state.ai_client, &req)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama local: {e}")))?,
"ollama_cloud" | "cloud" => {
let key = state.ollama_cloud_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"OLLAMA_CLOUD_KEY not configured".to_string(),
"OLLAMA_CLOUD_KEY not configured — set env or populate /root/llm_team_config.json".to_string(),
))?;
let r = ollama_cloud::chat(key, &*req_for_adapter)
ollama_cloud::chat(key, &req)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama cloud: {e}")))?;
(r, "ollama_cloud".to_string())
}
"openrouter" | "openrouter_free" => {
// Free-tier rescue rung. Added 2026-04-24 after iter 5
// repeated Ollama Cloud 502s on kimi-k2:1t — OpenRouter
// gives a different provider backbone as fallback.
let key = state.openrouter_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"OPENROUTER_API_KEY not configured".to_string(),
))?;
let r = openrouter::chat(key, &*req_for_adapter)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("openrouter: {e}")))?;
(r, "openrouter".to_string())
}
"gemini" => {
// Phase 40 provider adapter. Google Generative Language
// API via query-string key auth (not bearer).
let key = state.gemini_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"GEMINI_API_KEY not configured".to_string(),
))?;
let r = gemini::chat(key, &*req_for_adapter)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("gemini: {e}")))?;
(r, "gemini".to_string())
}
"claude" | "anthropic" => {
// Phase 40 provider adapter. Anthropic Messages API via
// x-api-key header + anthropic-version:2023-06-01.
let key = state.claude_key.as_deref().ok_or((
StatusCode::SERVICE_UNAVAILABLE,
"ANTHROPIC_API_KEY not configured".to_string(),
))?;
let r = claude::chat(key, &*req_for_adapter)
.await
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("claude: {e}")))?;
(r, "claude".to_string())
.map_err(|e| (StatusCode::BAD_GATEWAY, format!("ollama cloud: {e}")))?
}
other => {
return Err((
StatusCode::BAD_REQUEST,
format!("unknown provider '{other}' — supported: ollama, ollama_cloud, openrouter, gemini, claude"),
format!("unknown provider '{other}' — supported: ollama, ollama_cloud"),
));
}
};
@@ -345,7 +168,7 @@ async fn chat(
.map(|c| c.message.content.clone())
.unwrap_or_default();
lf.emit_chat(langfuse_trace::ChatTrace {
provider: used_provider.clone(),
provider: provider.clone(),
model: resp.model.clone(),
input: req.messages.clone(),
output,
@@ -360,19 +183,12 @@ async fn chat(
});
}
// Phase 40: per-provider usage tracking
{
let mut u = state.usage.write().await;
u.requests += 1;
u.prompt_tokens += resp.usage.prompt_tokens as u64;
u.completion_tokens += resp.usage.completion_tokens as u64;
u.total_tokens += resp.usage.total_tokens as u64;
let provider_usage = u.by_provider.entry(used_provider).or_default();
provider_usage.requests += 1;
provider_usage.prompt_tokens += resp.usage.prompt_tokens as u64;
provider_usage.completion_tokens += resp.usage.completion_tokens as u64;
provider_usage.total_tokens += resp.usage.total_tokens as u64;
}
Ok(Json(resp))
-217
View File
@@ -1,217 +0,0 @@
//! OpenRouter adapter — free-tier rescue rung for /v1/chat.
//!
//! Direct HTTPS call to `https://openrouter.ai/api/v1/chat/completions`
//! with Bearer auth. Mirrors the OpenAI-compatible shape so the model
//! list can be expanded without code changes. Added 2026-04-24 after
//! iter 5 hit repeated Ollama Cloud 502s on kimi-k2:1t — OpenRouter
//! free-tier models give us a different provider backbone as fallback.
//!
//! Key sourcing priority:
//! 1. Env var `OPENROUTER_API_KEY`
//! 2. `/home/profit/.env` (LLM Team convention)
//! 3. `/root/llm_team_config.json` → providers.openrouter.api_key
//!
//! First hit wins. Key is resolved once at gateway startup and stored
//! on `V1State.openrouter_key`.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use super::{ChatRequest, ChatResponse, Choice, Message, UsageBlock};
const OR_BASE_URL: &str = "https://openrouter.ai/api/v1";
const OR_TIMEOUT_SECS: u64 = 180;
pub fn resolve_openrouter_key() -> Option<String> {
if let Ok(k) = std::env::var("OPENROUTER_API_KEY") {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
// LLM Team UI writes its key to ~/.env on the host user — pick it up
// from the same source so the free-tier rescue path works without
// an explicit systemd Environment= line.
for path in ["/home/profit/.env", "/root/.env"] {
if let Ok(raw) = std::fs::read_to_string(path) {
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("OPENROUTER_API_KEY=") {
let k = rest.trim().trim_matches('"').trim_matches('\'');
if !k.is_empty() { return Some(k.to_string()); }
}
}
}
}
if let Ok(raw) = std::fs::read_to_string("/root/llm_team_config.json") {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
if let Some(k) = v.pointer("/providers/openrouter/api_key").and_then(|x| x.as_str()) {
if !k.trim().is_empty() { return Some(k.trim().to_string()); }
}
}
}
None
}
pub async fn chat(
key: &str,
req: &ChatRequest,
) -> Result<ChatResponse, String> {
// Strip the "openrouter/" prefix if the caller used the namespaced
// form so OpenRouter sees the raw model id (e.g. "openai/gpt-oss-120b:free").
let model = req.model.strip_prefix("openrouter/").unwrap_or(&req.model).to_string();
let body = ORChatBody {
model: model.clone(),
messages: req.messages.iter().map(|m| ORMessage {
role: m.role.clone(),
content: m.content.clone(),
}).collect(),
max_tokens: req.max_tokens.unwrap_or(800),
temperature: req.temperature.unwrap_or(0.3),
stream: false,
};
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(OR_TIMEOUT_SECS))
.build()
.map_err(|e| format!("build client: {e}"))?;
let t0 = std::time::Instant::now();
let resp = client
.post(format!("{}/chat/completions", OR_BASE_URL))
.bearer_auth(key)
// OpenRouter recommends Referer + Title for attribution; absent
// headers do not fail the call but help us see our traffic in
// their dashboard.
.header("HTTP-Referer", "https://vcp.devop.live")
.header("X-Title", "Lakehouse Scrum")
.json(&body)
.send()
.await
.map_err(|e| format!("openrouter.ai unreachable: {e}"))?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_else(|_| "?".into());
return Err(format!("openrouter.ai {}: {}", status, body));
}
let parsed: ORChatResponse = resp.json().await
.map_err(|e| format!("invalid openrouter response: {e}"))?;
let latency_ms = t0.elapsed().as_millis();
let choice = parsed.choices.into_iter().next()
.ok_or_else(|| "openrouter returned no choices".to_string())?;
let text = choice.message.content;
let prompt_tokens = parsed.usage.as_ref().map(|u| u.prompt_tokens).unwrap_or_else(|| {
let chars: usize = req.messages.iter().map(|m| m.content.chars().count()).sum();
((chars + 3) / 4) as u32
});
let completion_tokens = parsed.usage.as_ref().map(|u| u.completion_tokens).unwrap_or_else(|| {
((text.chars().count() + 3) / 4) as u32
});
tracing::info!(
target: "v1.chat",
provider = "openrouter",
model = %model,
prompt_tokens,
completion_tokens,
latency_ms = latency_ms as u64,
"openrouter chat completed",
);
Ok(ChatResponse {
id: format!("chatcmpl-{}", chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0)),
object: "chat.completion",
created: chrono::Utc::now().timestamp(),
model,
choices: vec![Choice {
index: 0,
message: Message { role: "assistant".into(), content: text },
finish_reason: choice.finish_reason.unwrap_or_else(|| "stop".into()),
}],
usage: UsageBlock {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens + completion_tokens,
},
})
}
// -- OpenRouter wire shapes (OpenAI-compatible) --
#[derive(Serialize)]
struct ORChatBody {
model: String,
messages: Vec<ORMessage>,
max_tokens: u32,
temperature: f64,
stream: bool,
}
#[derive(Serialize)]
struct ORMessage { role: String, content: String }
#[derive(Deserialize)]
struct ORChatResponse {
choices: Vec<ORChoice>,
#[serde(default)]
usage: Option<ORUsage>,
}
#[derive(Deserialize)]
struct ORChoice {
message: ORMessageResp,
#[serde(default)]
finish_reason: Option<String>,
}
#[derive(Deserialize)]
struct ORMessageResp { content: String }
#[derive(Deserialize)]
struct ORUsage { prompt_tokens: u32, completion_tokens: u32 }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_openrouter_key_does_not_panic() {
// Smoke test — all three sources may or may not be set depending
// on environment; just confirm the call returns cleanly.
let _ = resolve_openrouter_key();
}
#[test]
fn chat_body_serializes_to_openai_shape() {
let body = ORChatBody {
model: "openai/gpt-oss-120b:free".into(),
messages: vec![
ORMessage { role: "user".into(), content: "review this".into() },
],
max_tokens: 800,
temperature: 0.3,
stream: false,
};
let json = serde_json::to_string(&body).unwrap();
assert!(json.contains("\"model\":\"openai/gpt-oss-120b:free\""));
assert!(json.contains("\"messages\""));
assert!(json.contains("\"max_tokens\":800"));
assert!(json.contains("\"stream\":false"));
}
#[test]
fn model_prefix_strip_preserves_unprefixed() {
// If caller passes "openrouter/openai/gpt-oss-120b:free" we strip.
// If caller passes "openai/gpt-oss-120b:free" unchanged, we keep.
let cases = [
("openrouter/openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"),
("openai/gpt-oss-120b:free", "openai/gpt-oss-120b:free"),
("google/gemma-3-27b-it:free", "google/gemma-3-27b-it:free"),
];
for (input, expected) in cases {
let out = input.strip_prefix("openrouter/").unwrap_or(input);
assert_eq!(out, expected, "{input} should become {expected}");
}
}
}
-150
View File
@@ -1,150 +0,0 @@
//! `/v1/respond` — the **execution** API (distinct from `/v1/chat`, the
//! completion API).
//!
//! This is the consolidation move called out in the 2026-04-23 session:
//! lift the proven pipeline from `tests/multi-agent/orchestrator.ts`
//! (executor → reviewer → escalate → validate → seal playbook →
//! write-through to KB) into the gateway, so the production path has
//! the intelligence the tests already proved.
//!
//! `/v1/chat` stays a naive completion proxy for callers that want one.
//! `/v1/respond` is where the loop lives. Every orchestrator-style
//! caller migrates here and the TS harnesses become thin clients.
//!
//! This file holds the HTTP surface + request/response shapes. The loop
//! itself lives in `execution_loop::ExecutionLoop`.
use axum::{extract::State, http::StatusCode, Json};
use serde::{Deserialize, Serialize};
use super::V1State;
use crate::execution_loop::{ExecutionLoop, LogEntry, RespondOutcome};
/// A structured task — mirrors `TaskSpec` in `tests/multi-agent/agent.ts`.
/// Kept deliberately open so non-staffing task classes (code-gen,
/// DevOps-long-horizon) can land without a schema fight.
#[derive(Deserialize, Debug, Clone)]
pub struct RespondRequest {
/// Task class — routes to the right truth rules + validator. For the
/// staffing substrate: `staffing.fill`, `staffing.rescue`,
/// `staffing.sms_draft`. Truth-layer lookup is a no-op until a rule
/// set is registered for the class.
pub task_class: String,
/// Human-readable operation description — becomes the playbook
/// `operation` field on seal, and the primary signal for
/// playbook_memory embedding.
pub operation: String,
/// Free-form structured context. Passed to the executor prompt and
/// to the playbook seeder. Staffing tasks expect
/// `{target_role, target_count, target_city, target_state, approach_hint}`
/// but nothing here validates that — the validator crate will (Phase 43).
#[serde(default)]
pub spec: serde_json::Value,
/// Executor model. Defaults to the hot-path local model if omitted.
/// See orchestrator.ts:28 (`EXECUTOR_MODEL = "qwen3.5:latest"`).
#[serde(default)]
pub executor_model: Option<String>,
/// Reviewer model. Defaults to the hot-path local reviewer.
/// See orchestrator.ts:29 (`REVIEWER_MODEL = "qwen3:latest"`).
#[serde(default)]
pub reviewer_model: Option<String>,
/// Hard cap on executor turns. Default matches orchestrator.ts:30
/// (`MAX_TURNS = 12`). Cloud escalation counts as a turn.
#[serde(default)]
pub max_turns: Option<u32>,
}
#[derive(Serialize)]
pub struct RespondResponse {
/// `ok` = consensus reached, playbook sealed. `failed` = loop ran
/// out of turns or hit the drift cap. `blocked` = truth-layer
/// veto (Phase 42 rule citation in `error`).
pub status: &'static str,
/// The final artifact — for staffing fills, `{fills: [{candidate_id, name}]}`.
/// Empty on failure / block.
pub artifact: serde_json::Value,
/// Structured cross-turn log. Same shape as orchestrator.ts LogEntry
/// so existing tooling (kb extractors, fact_extractor.ts) reads it
/// without change.
pub log: Vec<LogEntry>,
/// Iteration count actually used. ≤ max_turns. Stamped on
/// outcomes.jsonl per the indicator audit (2026-04-23).
pub iterations: u32,
/// Error message on non-ok status. Truth-rule citations land here
/// when `status == "blocked"`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub async fn respond(
State(state): State<V1State>,
Json(req): Json<RespondRequest>,
) -> Result<Json<RespondResponse>, (StatusCode, String)> {
if req.operation.is_empty() {
return Err((StatusCode::BAD_REQUEST, "operation must be non-empty".into()));
}
if req.task_class.is_empty() {
return Err((StatusCode::BAD_REQUEST, "task_class must be non-empty".into()));
}
let mut loop_runner = ExecutionLoop::new(state, req);
let outcome = loop_runner.run().await.map_err(|e| {
(StatusCode::INTERNAL_SERVER_ERROR, format!("execution loop: {e}"))
})?;
let (status, error) = match &outcome {
RespondOutcome::Ok { .. } => ("ok", None),
RespondOutcome::Failed { reason, .. } => ("failed", Some(reason.clone())),
RespondOutcome::Blocked { reason, .. } => ("blocked", Some(reason.clone())),
};
Ok(Json(RespondResponse {
status,
artifact: outcome.artifact(),
log: outcome.into_log(),
iterations: loop_runner.turns_used(),
error,
}))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn respond_request_parses_minimal() {
let raw = r#"{
"task_class": "staffing.fill",
"operation": "fill: Welder x2 in Toledo, OH"
}"#;
let r: RespondRequest = serde_json::from_str(raw).unwrap();
assert_eq!(r.task_class, "staffing.fill");
assert_eq!(r.executor_model, None);
assert_eq!(r.max_turns, None);
}
#[test]
fn respond_request_parses_full() {
let raw = r#"{
"task_class": "staffing.fill",
"operation": "fill: Welder x2 in Toledo, OH",
"spec": {"target_role": "Welder", "target_count": 2, "target_city": "Toledo", "target_state": "OH"},
"executor_model": "qwen3.5:latest",
"reviewer_model": "qwen3:latest",
"max_turns": 12
}"#;
let r: RespondRequest = serde_json::from_str(raw).unwrap();
assert_eq!(r.executor_model.as_deref(), Some("qwen3.5:latest"));
assert_eq!(r.max_turns, Some(12));
assert_eq!(r.spec["target_count"], 2);
}
}
-47
View File
@@ -1,47 +0,0 @@
use serde::Serialize;
use truth::default_truth_store;
// Note: truth_router() was a stub wrapper around a single /context route
// that nothing called — v1/mod.rs wires get(truth::context) directly
// onto its own router. Removed 2026-04-24 along with its #[allow(dead_code)]
// attribute; the handler below is the real surface.
#[derive(Serialize)]
pub struct ContextResponse {
pub task_classes: Vec<String>,
pub rules: Vec<RuleInfo>,
}
#[derive(Serialize)]
pub struct RuleInfo {
pub id: String,
pub task_class: String,
pub description: String,
}
pub async fn context() -> axum::Json<ContextResponse> {
let store = default_truth_store();
let task_classes: Vec<String> = vec![
"staffing.fill".to_string(),
"staffing.rescue".to_string(),
"staffing.sms_draft".to_string(),
"staffing.any".to_string(),
];
let mut rules = Vec::new();
for tc in &task_classes {
for rule in store.get_rules(tc) {
rules.push(RuleInfo {
id: rule.id.clone(),
task_class: rule.task_class.clone(),
description: rule.description.clone(),
});
}
}
axum::Json(ContextResponse {
task_classes,
rules,
})
}
-1
View File
@@ -8,7 +8,6 @@ shared = { path = "../shared" }
storaged = { path = "../storaged" }
catalogd = { path = "../catalogd" }
vectord = { path = "../vectord" }
journald = { path = "../journald" }
tokio = { workspace = true }
axum = { workspace = true, features = ["multipart"] }
lopdf = { workspace = true }
+3 -3
View File
@@ -2,9 +2,10 @@
/// When a source changes format (columns renamed, added, removed, type changed),
/// the system detects the diff and can auto-map using AI or heuristic matching.
use arrow::datatypes::{DataType, SchemaRef};
use arrow::datatypes::{DataType, Schema, SchemaRef};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
/// A detected change between two schema versions.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -222,8 +223,7 @@ fn find_similar_column<'a>(
#[cfg(test)]
mod tests {
use super::*;
use arrow::datatypes::{Field, Schema};
use std::sync::Arc;
use arrow::datatypes::Field;
fn schema(fields: Vec<(&str, DataType)>) -> SchemaRef {
Arc::new(Schema::new(
+1 -127
View File
@@ -3,7 +3,7 @@ use axum::{
extract::{Multipart, Path, Query, State},
http::{HeaderMap, StatusCode},
response::IntoResponse,
routing::{get, post},
routing::{delete, get, patch, post},
};
use bytes::Bytes;
use object_store::ObjectStore;
@@ -33,11 +33,6 @@ pub struct IngestState {
/// Scheduled-ingest registry. The scheduler task runs against this
/// store; HTTP CRUD endpoints write through it.
pub schedules: schedule::ScheduleStore,
/// Event journal for ADR-012 mutation history. Optional for back-compat
/// with callers (like scheduled ingest tests) that don't wire it yet.
/// When present, successful ingests emit a record_ingest event — closes
/// P9-001 on the file-upload path. (2026-04-23)
pub journal: Option<journald::journal::Journal>,
}
/// Push `DatasetAppended` triggers for every HNSW index bound to this
@@ -141,22 +136,6 @@ async fn ingest_file(
Ok(result) => {
if !result.deduplicated {
notify_agent_on_append(&state, &result.dataset_name).await;
// P9-001 fix (2026-04-23): emit a mutation event on every
// non-deduplicated ingest. Dedup no-ops don't need events
// (ADR-020 register() is already idempotent on same fingerprint).
if let Some(ref journal) = state.journal {
if let Err(e) = journal.record_ingest(
&result.dataset_name,
result.rows as usize,
"ingest_api",
&filename,
).await {
tracing::warn!(
"journal record_ingest failed for '{}': {}",
result.dataset_name, e,
);
}
}
}
if result.deduplicated {
Ok((StatusCode::OK, Json(result)))
@@ -651,108 +630,3 @@ async fn run_schedule_now(
}
Ok(Json(outcome))
}
// ─── Tests ───
#[cfg(test)]
mod journal_integration_tests {
//! P9-001 integration test: prove that a successful ingest produces a
//! journal.record_ingest event. Block 2 on PR #10 was "journal event
//! verified live" being unbacked by the diff. This test makes the
//! verification committed and reproducible.
use journald::journal::{Event, Journal};
use object_store::memory::InMemory;
use std::sync::Arc;
// Helper: build a bare Journal against an in-memory object store.
// Flush threshold 1 so every recorded event is persisted immediately.
fn test_journal() -> Journal {
let store: Arc<dyn object_store::ObjectStore> = Arc::new(InMemory::new());
Journal::new(store, 1)
}
#[tokio::test]
async fn journal_record_ingest_increments_counter() {
// Arrange — fresh journal, counter starts at zero.
let journal = test_journal();
let stats0 = journal.stats().await;
assert_eq!(stats0.total_events_created, 0);
assert_eq!(stats0.buffer_events, 0);
// Act — simulate what the /ingest/file success path does.
journal
.record_ingest("test_dataset", 42, "ingest_api", "probe.csv")
.await
.expect("record_ingest should succeed");
// Assert — counter advanced, event exists. With threshold=1 the
// event flushed to store; with threshold>N it would be in-buffer.
let stats1 = journal.stats().await;
assert_eq!(stats1.total_events_created, 1, "counter should reflect one recorded event");
// Assert — the event is retrievable by entity.
let history = journal
.get_entity_history("batch:42")
.await
.expect("history lookup");
assert_eq!(history.len(), 1, "one event should be visible in history");
let ev = &history[0];
assert_eq!(ev.action, "ingest");
assert_eq!(ev.entity_type, "test_dataset");
assert_eq!(ev.actor, "ingest_api");
assert!(
ev.new_value.contains("probe.csv"),
"new_value should carry source filename, got: {}",
ev.new_value
);
}
#[tokio::test]
async fn optional_journal_field_none_is_valid_back_compat() {
// IngestState.journal is Option<Journal>. Back-compat path: when
// the field is None, the ingest handler MUST still succeed — the
// journal call is fire-and-forget, never load-bearing.
//
// This test asserts the type shape: Option<Journal> is what we
// expect. If a refactor makes it mandatory, this test forces an
// explicit re-consideration.
let none_journal: Option<Journal> = None;
assert!(none_journal.is_none());
let some_journal: Option<Journal> = Some(test_journal());
assert!(some_journal.is_some());
}
#[tokio::test]
async fn journal_record_event_fields_match_adr_012_schema() {
// ADR-012 locks the event schema: entity_type, entity_id, field,
// action, old_value, new_value, actor, source, workspace_id plus
// the auto-assigned event_id + timestamp. This test pins the
// field names so a future refactor can't silently drop one.
let journal = test_journal();
let base = Event {
event_id: String::new(),
timestamp: chrono::Utc::now(),
entity_type: "candidate".into(),
entity_id: "CAND-0001".into(),
field: "phone".into(),
action: "update".into(),
old_value: "555-0000".into(),
new_value: "555-9999".into(),
actor: "recruiter".into(),
source: "api".into(),
workspace_id: "ws-x".into(),
};
journal.record(base).await.expect("record should accept full-schema event");
let h = journal
.get_entity_history("CAND-0001")
.await
.expect("lookup");
assert_eq!(h.len(), 1);
assert_eq!(h[0].field, "phone");
assert_eq!(h[0].old_value, "555-0000");
assert_eq!(h[0].new_value, "555-9999");
assert_eq!(h[0].workspace_id, "ws-x");
}
}
+6 -8
View File
@@ -72,14 +72,12 @@ async fn process_inbox(
let path = entry.path();
// Skip directories and hidden files. Bind filename once via
// let-else so the subsequent use is unwrap-free — previous
// version relied on a map_or guard above + an .unwrap() here
// being consistent, which is a fragile invariant.
if path.is_dir() { continue; }
let Some(fn_os) = path.file_name() else { continue; };
let filename = fn_os.to_string_lossy().to_string();
if filename.starts_with('.') { continue; }
// Skip directories and hidden files
if path.is_dir() || path.file_name().map_or(true, |n| n.to_string_lossy().starts_with('.')) {
continue;
}
let filename = path.file_name().unwrap().to_string_lossy().to_string();
tracing::info!("watcher: found new file '{}'", filename);
// Read file
+1 -1
View File
@@ -5,7 +5,7 @@
/// Storage: events buffer in memory, flush to Parquet periodically.
/// Query: load Parquet files, filter by entity/field/actor/time.
use arrow::array::{ArrayRef, RecordBatch, StringArray};
use arrow::array::{ArrayRef, RecordBatch, StringArray, UInt64Array};
use arrow::datatypes::{DataType, Field, Schema};
use chrono::{DateTime, Utc};
use object_store::ObjectStore;
-1
View File
@@ -7,7 +7,6 @@ edition = "2024"
shared = { path = "../shared" }
catalogd = { path = "../catalogd" }
storaged = { path = "../storaged" }
truth = { path = "../truth" }
tokio = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }
+5 -8
View File
@@ -84,17 +84,14 @@ pub async fn compact(
// Load deltas
let delta_batches = load_deltas(store, dataset_name).await?;
let delta_count = delta_batches.len();
// Row counts captured before extend; previously base_rows subtracted delta_count (files) from rows — unit mismatch.
let base_row_count: usize = base_batches.iter().map(|b| b.num_rows()).sum();
let delta_row_count: usize = delta_batches.iter().map(|b| b.num_rows()).sum();
let has_tombstones = !tombstones.is_empty();
let nothing_to_do = delta_batches.is_empty() && !has_tombstones;
if nothing_to_do {
return Ok(CompactResult {
base_rows: base_row_count,
base_rows: base_batches.iter().map(|b| b.num_rows()).sum(),
delta_rows: 0,
final_rows: base_row_count,
final_rows: base_batches.iter().map(|b| b.num_rows()).sum(),
deltas_merged: 0,
tombstones_applied: 0,
rows_dropped_by_tombstones: 0,
@@ -102,7 +99,7 @@ pub async fn compact(
}
base_batches.extend(delta_batches);
let pre_filter_rows: usize = base_row_count + delta_row_count;
let pre_filter_rows: usize = base_batches.iter().map(|b| b.num_rows()).sum();
// If primary key specified, deduplicate (keep last occurrence)
let merged_batches = if let Some(_pk) = primary_key_col {
@@ -186,8 +183,8 @@ pub async fn compact(
);
Ok(CompactResult {
base_rows: base_row_count,
delta_rows: delta_row_count,
base_rows: pre_filter_rows - delta_count, // rough base-before-deltas
delta_rows: delta_count,
final_rows,
deltas_merged: delta_count,
tombstones_applied: tombstones.len(),
+1 -119
View File
@@ -9,9 +9,7 @@ use axum::{
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use truth::{RuleAction, TruthStore};
use crate::cache::CacheStats;
use crate::context::QueryEngine;
use crate::delta;
use crate::paged::ResultStore;
@@ -20,26 +18,12 @@ use crate::paged::ResultStore;
pub struct QueryState {
pub engine: QueryEngine,
pub result_store: ResultStore,
// Policy gate for incoming SQL. Every /sql and /paged request is
// evaluated against this store before hitting DataFusion. Added for
// P42-002 ("raw SQL forwarded without schema or policy gate") after
// the scrum master's queryd/service.rs finding looped across iters
// 3-5 without ever being reachable by the 6-line auto-applier.
pub truth: Arc<TruthStore>,
}
pub fn router(engine: QueryEngine) -> Router {
router_with_truth(engine, Arc::new(truth::sql_query_guard_store()))
}
/// Test/integration hook: construct the router with a caller-supplied
/// TruthStore so tests can assert reject/pass behavior deterministically
/// without depending on the default needle list.
pub fn router_with_truth(engine: QueryEngine, truth: Arc<TruthStore>) -> Router {
let state = QueryState {
engine: engine.clone(),
result_store: ResultStore::new(100, 50), // 100 rows/page, keep 50 results
truth,
};
Router::new()
.route("/health", get(health))
@@ -69,11 +53,6 @@ struct QueryResponse {
columns: Vec<ColumnInfo>,
rows: serde_json::Value,
row_count: usize,
// Elapsed wall time from handler entry to response. Required for
// audit-log parity — gateway's audit row previously stored null here.
// Scrum iter 9 finding, populated from std::time::Instant captured
// at the top of execute_query / paged_query.
latency_ms: u64,
}
#[derive(Serialize)]
@@ -93,41 +72,12 @@ fn batches_to_json(batches: &[RecordBatch]) -> Result<serde_json::Value, String>
serde_json::from_slice(&buf).map_err(|e| format!("JSON parse error: {e}"))
}
/// Evaluate the request SQL against the configured TruthStore. Returns
/// the Reject/Block message on the first failing mandatory rule so the
/// handler can short-circuit. Returns None when all rules pass (or when
/// the failures' declared action is non-mandatory like Redact/Pass).
fn sql_policy_check(truth: &TruthStore, sql: &str) -> Option<String> {
let ctx = serde_json::json!({ "sql": sql });
for outcome in truth.evaluate("sql_query", &ctx) {
if !outcome.passed {
// FieldEmpty / FieldContainsAny etc. are enforced only when
// condition HOLDS (i.e. passed=true). Below means "passed=false",
// so the rule condition did not hold — no enforcement.
continue;
}
match &outcome.action {
RuleAction::Reject { message } | RuleAction::Block { message } => {
return Some(message.clone());
}
_ => {}
}
}
None
}
async fn execute_query(
State(state): State<QueryState>,
Json(req): Json<QueryRequest>,
) -> impl IntoResponse {
let started = std::time::Instant::now();
tracing::info!("executing query: {}", req.sql);
if let Some(reason) = sql_policy_check(&state.truth, &req.sql) {
tracing::warn!("sql rejected by truth gate: {reason}");
return Err((StatusCode::FORBIDDEN, reason));
}
match state.engine.query(&req.sql).await {
Ok(batches) => {
if batches.is_empty() {
@@ -135,7 +85,6 @@ async fn execute_query(
columns: vec![],
rows: serde_json::Value::Array(vec![]),
row_count: 0,
latency_ms: started.elapsed().as_millis() as u64,
}));
}
@@ -154,7 +103,6 @@ async fn execute_query(
columns,
rows,
row_count,
latency_ms: started.elapsed().as_millis() as u64,
}))
}
Err(e) => Err((StatusCode::BAD_REQUEST, e)),
@@ -168,10 +116,6 @@ async fn paged_query(
Json(req): Json<QueryRequest>,
) -> impl IntoResponse {
tracing::info!("paged query: {}", req.sql);
if let Some(reason) = sql_policy_check(&state.truth, &req.sql) {
tracing::warn!("paged sql rejected by truth gate: {reason}");
return Err((StatusCode::FORBIDDEN, reason));
}
match state.result_store.execute_and_store(&state.engine, &req.sql).await {
Ok(handle) => Ok(Json(handle)),
Err(e) => Err((StatusCode::BAD_REQUEST, e)),
@@ -268,65 +212,3 @@ async fn compact_dataset(
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}
#[cfg(test)]
mod sql_policy_tests {
use super::*;
use truth::sql_query_guard_store;
// These tests exercise the policy gate without spinning up a DataFusion
// engine — they only need `TruthStore`. Purpose: prove the P42-002
// enforcement point actually rejects destructive SQL. This is the
// regression guard for the queryd/service.rs finding that looped
// across scrum iters 3-5.
#[test]
fn blocks_drop_table() {
let store = sql_query_guard_store();
let reason = sql_policy_check(&store, "DROP TABLE users").expect("must reject");
assert!(reason.contains("destructive"), "reason: {reason}");
}
#[test]
fn blocks_delete_from() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "delete from t where 1=1").is_some());
}
#[test]
fn blocks_truncate() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "TRUNCATE workers").is_some());
}
#[test]
fn blocks_empty_sql() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "").is_some());
}
#[test]
fn allows_benign_select() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "SELECT count(*) FROM workers").is_none());
}
#[test]
fn allows_select_with_deleted_word_in_column() {
// Substring match is narrow ("delete from", not "delete"), so a
// column named `deleted_at` doesn't trip the guard. Important
// check — false positives on benign queries would make the gate
// unusable in practice.
let store = sql_query_guard_store();
assert!(
sql_policy_check(&store, "SELECT deleted_at FROM t").is_none(),
"column names containing 'delete' must not be rejected"
);
}
#[test]
fn case_insensitive_match_catches_mixed_case() {
let store = sql_query_guard_store();
assert!(sql_policy_check(&store, "Drop Table X").is_some());
}
}
+3
View File
@@ -2,12 +2,15 @@
/// Each workspace tracks an agent's activity on a specific contract or search,
/// with daily/weekly/monthly tiers and instant handoff capability.
use arrow::array::{ArrayRef, RecordBatch, StringArray, Int64Array};
use arrow::datatypes::{DataType, Field, Schema};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use crate::delta;
use object_store::ObjectStore;
/// Retention tier for workspace data.
-2
View File
@@ -4,5 +4,3 @@ pub mod arrow_helpers;
pub mod config;
pub mod pii;
pub mod secrets;
pub mod model_matrix;
pub mod profiles;
-69
View File
@@ -1,69 +0,0 @@
//! Per-model token accounting. Entry point for the ModelMatrix work
//! the aibridge `context::estimate_tokens` deprecation has been pointing
//! at. Starts minimal — just `estimate_tokens` — so call sites can
//! migrate off the deprecated helper. Extend with per-model context
//! windows, max_tokens defaults, provider hints, etc. as we move the
//! rest of `aibridge::context::known_windows` over.
/// Namespace for per-model token + context accounting. Methods are
/// associated functions — no instance required — because the underlying
/// estimates are deterministic and stateless.
pub struct ModelMatrix;
impl ModelMatrix {
/// Rough token count — char count divided by 4, rounded up. This
/// is the same heuristic OpenAI's cookbook uses for English text;
/// it's within ±15% of BPE tokenizers for code + prose and doesn't
/// require a tokenizer lookup. Good enough for budget math where
/// the goal is "don't blow the context window" rather than exact
/// billing.
///
/// Moved from `aibridge::context::estimate_tokens` (still there with
/// a `#[deprecated]` pointer — callers should migrate here). Empty
/// string → 0; one char → 1 (ceiling of 1/4 = 1).
pub fn estimate_tokens(text: &str) -> usize {
(text.chars().count() + 3) / 4
}
}
#[cfg(test)]
mod tests {
use super::ModelMatrix;
#[test]
fn empty_string_is_zero_tokens() {
assert_eq!(ModelMatrix::estimate_tokens(""), 0);
}
#[test]
fn three_chars_is_one_token() {
// 3 → ceil(3/4) = 1. Matches the deprecated helper's behavior
// so the migration is a drop-in replacement.
assert_eq!(ModelMatrix::estimate_tokens("abc"), 1);
}
#[test]
fn four_chars_is_one_token() {
assert_eq!(ModelMatrix::estimate_tokens("abcd"), 1);
}
#[test]
fn five_chars_is_two_tokens() {
assert_eq!(ModelMatrix::estimate_tokens("abcde"), 2);
}
#[test]
fn counts_chars_not_bytes() {
// Multi-byte UTF-8 chars count as 1 char each — important for
// prompts with emoji or non-ASCII text. "héllo" is 5 chars
// (5 unicode scalars) → ceil(5/4) = 2 tokens, same as "hello".
assert_eq!(ModelMatrix::estimate_tokens("héllo"), 2);
assert_eq!(ModelMatrix::estimate_tokens("📚📚📚📚"), 1); // 4 chars
}
#[test]
fn large_text_scales_linearly() {
assert_eq!(ModelMatrix::estimate_tokens(&"x".repeat(400)), 100);
assert_eq!(ModelMatrix::estimate_tokens(&"x".repeat(401)), 101);
}
}
-14
View File
@@ -1,14 +0,0 @@
//! ExecutionProfile — the Phase 41 rename of Phase 17's ModelProfile.
//!
//! Carries what's needed to RUN inference: model tag, dataset bindings,
//! HNSW config, embed model, bucket binding. Today this is a type
//! alias over `crate::types::ModelProfile` — the PRD's
//! "Backward compat: ModelProfile still loads, aliased to
//! ExecutionProfile" line, honored literally.
//!
//! When the migration off the old name finishes, this file can either
//! absorb the full struct definition or continue as an alias. Callers
//! should reference `ExecutionProfile` going forward; `ModelProfile`
//! stays exported from `types` for on-disk schema compat.
pub use crate::types::ModelProfile as ExecutionProfile;
-38
View File
@@ -1,38 +0,0 @@
//! MemoryProfile — how the agent's execution memory is kept.
//!
//! Phase 41 decomposition: the Phase 19 playbook_memory + Phase 26
//! successful_playbooks + Phase 45 doc_refs all need per-profile
//! tuning. Rather than bolt those onto ExecutionProfile, they live
//! here so a "thin" execution profile can reuse a "fat" memory
//! profile and vice versa.
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct MemoryProfile {
pub id: String,
#[serde(default)]
pub description: String,
/// Phase 19: ceiling for playbook_memory boost on retrieval. 0
/// disables the boost entirely.
#[serde(default = "default_boost_ceiling")]
pub playbook_boost_ceiling: f32,
/// Phase 26: max history entries retained before rotation.
#[serde(default = "default_history_cap")]
pub history_cap: usize,
/// Phase 45: stale threshold for doc_refs before drift check
/// fires (hours).
#[serde(default = "default_stale_hours")]
pub doc_stale_hours: u32,
/// Phase 28: auto-retire playbooks that fail 3+ consecutive runs.
#[serde(default = "default_true")]
pub auto_retire_on_failure: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub created_by: String,
}
fn default_boost_ceiling() -> f32 { 0.35 }
fn default_history_cap() -> usize { 1000 }
fn default_stale_hours() -> u32 { 168 } // one week
fn default_true() -> bool { true }
-28
View File
@@ -1,28 +0,0 @@
//! Phase 41 profile types.
//!
//! The existing `ModelProfile` (Phase 17) is aliased as
//! `ExecutionProfile` here — it continues to carry the model +
//! bindings + HNSW config needed to run inference. Three new profile
//! types land alongside: `RetrievalProfile`, `MemoryProfile`,
//! `ObserverProfile` — each owns a distinct slice of what used to be
//! bundled.
//!
//! Backward-compat rule (PRD Phase 41): existing `ModelProfile` on
//! disk continues to deserialize unchanged. New fields on the new
//! profile types are `#[serde(default)]` so old payloads load with
//! empty defaults.
//!
//! These are the canonical shapes — downstream code converts via
//! `From<ModelProfile> for ExecutionProfile` (they're the same struct
//! today, just named differently) and constructs the other three
//! as needed.
pub mod execution;
pub mod retrieval;
pub mod memory;
pub mod observer;
pub use execution::ExecutionProfile;
pub use memory::MemoryProfile;
pub use observer::ObserverProfile;
pub use retrieval::RetrievalProfile;
-38
View File
@@ -1,38 +0,0 @@
//! ObserverProfile — how loudly the observer logs this workload.
//!
//! Phase 41 decomposition: the observer's alert thresholds, escalation
//! cadence, and log retention need per-workload tuning. Hot-path
//! staffing workflows want aggressive alerting; batch backfills want
//! quieter. This profile is read by mcp-server/observer.ts at
//! activation-time.
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ObserverProfile {
pub id: String,
#[serde(default)]
pub description: String,
/// How many consecutive failures trigger a cluster escalation to
/// LLM Team `/v1/chat` (qwen3-coder:480b).
#[serde(default = "default_failure_cluster_size")]
pub failure_cluster_size: u32,
/// Minimum seconds between alert emails for the same sig_hash.
/// Prevents alert storms during a regression.
#[serde(default = "default_alert_cooldown")]
pub alert_cooldown_secs: u32,
/// Observer ring buffer size. Older events fall off when full.
#[serde(default = "default_ring_size")]
pub ring_size: usize,
/// Whether to forward events to external Langfuse
/// (/v1/langfuse_trace). Off by default.
#[serde(default)]
pub forward_to_langfuse: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub created_by: String,
}
fn default_failure_cluster_size() -> u32 { 3 }
fn default_alert_cooldown() -> u32 { 300 } // 5 minutes
fn default_ring_size() -> usize { 2000 }
-52
View File
@@ -1,52 +0,0 @@
//! RetrievalProfile — what + how the agent reaches into memory.
//!
//! Phase 41 decomposition: the old ModelProfile bundled "what dataset
//! can I read" (bound_datasets) AND "how do I rank results"
//! (hnsw_config) with the model tag. Retrieval concerns split out here
//! so a profile can swap its retrieval strategy without re-activating
//! the model.
//!
//! Fields chosen for what's actually varied per-workload today:
//! - `top_k` / `rerank_top_k` — how many hits to fetch + rerank
//! - `freshness_cutoff_days` — Phase 45 doc-drift uses this
//! - `boost_playbook_memory` — Phase 19 meta-index feedback
//! - `enforce_sensitivity_gates` — Phase 13 access-control integration
//!
//! All fields are `#[serde(default)]` so loading a profile file that
//! predates Phase 41 works without migration.
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RetrievalProfile {
/// Unique id — slug form, separate namespace from ExecutionProfile.
pub id: String,
/// Free-text operator description.
#[serde(default)]
pub description: String,
/// Default top-K for /vectors/search + /vectors/hybrid.
#[serde(default = "default_top_k")]
pub top_k: u32,
/// How many of the top-K to pass through the reranker. 0 disables
/// reranking for this profile.
#[serde(default = "default_rerank_top_k")]
pub rerank_top_k: u32,
/// Don't consider playbooks / docs older than this (days). 0 or
/// absent = no freshness filter.
#[serde(default)]
pub freshness_cutoff_days: u32,
/// Phase 19: boost workers/results by playbook_memory similarity.
#[serde(default)]
pub boost_playbook_memory: bool,
/// Phase 13: apply access-control masking on sensitive columns.
/// Default on — safety-first.
#[serde(default = "default_true")]
pub enforce_sensitivity_gates: bool,
pub created_at: chrono::DateTime<chrono::Utc>,
#[serde(default)]
pub created_by: String,
}
fn default_top_k() -> u32 { 10 }
fn default_rerank_top_k() -> u32 { 5 }
fn default_true() -> bool { true }
-14
View File
@@ -269,20 +269,6 @@ pub struct ModelProfile {
/// / 5M+ vector workloads.
#[serde(default)]
pub vector_backend: VectorBackend,
/// Phase 41: Profile type for routing + activation behavior.
/// Default Execution for backward compatibility.
#[serde(default)]
pub profile_type: ProfileType,
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum ProfileType {
#[default]
Execution,
Retrieval,
Memory,
Observer,
}
fn default_embed_model() -> String { "nomic-embed-text".to_string() }
-11
View File
@@ -1,11 +0,0 @@
[package]
name = "truth"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
toml = { workspace = true }
-49
View File
@@ -1,49 +0,0 @@
//! DevOps task-class rules — scaffold for the long-horizon phase.
//!
//! Phase 42 PRD: "Terraform/Ansible rule shapes are scaffolded but
//! unpopulated until the long-horizon phase. Keeps the dispatcher
//! signature stable so no refactor needed later."
//!
//! This module is intentionally minimal. It registers no rules yet.
//! The `devops_rules` function exists so callers can compose it onto
//! a store (e.g. `devops_rules(staffing_rules(TruthStore::new()))`)
//! without branching on whether the DevOps phase has landed.
//!
//! When the long-horizon phase fleshes out the DevOps rule set, the
//! implementations drop in here — same `RuleCondition` primitives, same
//! `TruthStore::evaluate` contract, zero upstream refactor.
use crate::TruthStore;
/// Register DevOps rules on the store. Currently a no-op scaffold —
/// no rules are added. Safe to compose with other rule-set functions.
///
/// Planned task classes (not yet populated):
/// - `devops.terraform_plan` — `terraform validate` + pre-plan
/// sanity checks (no destroys without confirm flag, etc.)
/// - `devops.ansible_playbook` — `ansible-lint` + privileged-task
/// gates (no `become: true` on untagged hosts)
/// - `devops.shell_command` — whitelist / blocklist for
/// AI-generated shell invocations (covers what Phase 42
/// queryd SQL gate does for SQL — same idea, shell surface)
pub fn devops_rules(store: TruthStore) -> TruthStore {
// Intentionally empty. See module-level doc for the phased rollout.
store
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn devops_rules_is_a_noop_for_now() {
// Scaffold guarantee: composing devops_rules onto an empty
// store must not add any rules. Future long-horizon work will
// populate this and the assertion shifts to counting the
// expected additions.
let store = devops_rules(TruthStore::new());
assert_eq!(store.get_rules("devops.terraform_plan").len(), 0);
assert_eq!(store.get_rules("devops.ansible_playbook").len(), 0);
assert_eq!(store.get_rules("devops.shell_command").len(), 0);
}
}
-610
View File
@@ -1,610 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub mod staffing;
pub mod devops;
pub mod loader;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TruthRule {
pub id: String,
pub task_class: String,
pub description: String,
pub condition: RuleCondition,
pub action: RuleAction,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RuleCondition {
Always,
FieldEquals { field: String, value: String },
FieldMismatch { field: String, value: String },
FieldEmpty { field: String },
FieldGreater { field: String, threshold: i64 },
// Case-insensitive substring scan — true if the field value contains
// ANY of `needles`. Added for SQL/command guards where rules of the
// form "sql must not contain DROP/DELETE/TRUNCATE" need to express
// enforcement as a passing precondition being absent.
FieldContainsAny { field: String, needles: Vec<String> },
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum RuleAction {
Pass,
Reject { message: String },
Redact { fields: Vec<String> },
Block { message: String },
}
#[derive(Default)]
pub struct TruthStore {
rules: HashMap<String, Vec<TruthRule>>,
}
impl TruthStore {
pub fn new() -> Self {
Self::default()
}
pub fn add_rule(&mut self, rule: TruthRule) {
self.rules
.entry(rule.task_class.clone())
.or_default()
.push(rule);
}
/// All rule IDs across every task class. Used by the file loader
/// to detect duplicate-ID collisions before registering new rules.
pub fn all_rule_ids(&self) -> std::collections::HashSet<String> {
self.rules
.values()
.flat_map(|v| v.iter().map(|r| r.id.clone()))
.collect()
}
pub fn get_rules(&self, task_class: &str) -> Vec<&TruthRule> {
self.rules
.get(task_class)
.map(|v| v.iter().collect())
.unwrap_or_default()
}
/// Legacy API: returns the list of actions registered for a task class
/// without evaluating conditions. Retained for backward compatibility
/// with callers that only want the action catalog. New callers should
/// prefer `evaluate()`, which actually walks `RuleCondition` against
/// a context and reports per-rule pass/fail.
pub fn check(&self, task_class: &str) -> Vec<RuleAction> {
let rules = self.get_rules(task_class);
rules
.into_iter()
.map(|r| r.action.clone())
.collect()
}
/// Evaluate every rule registered for `task_class` against `ctx`,
/// returning one `RuleOutcome` per rule. `passed = true` means the
/// rule's `condition` held; the rule's action is still attached so
/// callers can distinguish "passed and therefore no-op" (RuleAction::Pass)
/// from "passed and apply Redact". `passed = false` means the condition
/// failed — callers should treat the attached action as the enforcement
/// response (Reject/Block).
///
/// Fixed P42-001 (2026-04-23): previously `check()` returned all actions
/// unconditionally — the `RuleCondition` field was ignored. Now every
/// rule is actually walked against the provided context.
pub fn evaluate(&self, task_class: &str, ctx: &serde_json::Value) -> Vec<RuleOutcome> {
self.get_rules(task_class)
.into_iter()
.map(|r| RuleOutcome {
rule_id: r.id.clone(),
passed: evaluate_condition(&r.condition, ctx),
action: r.action.clone(),
})
.collect()
}
}
/// Result of evaluating one rule against a context. `passed` reports
/// whether the condition held; `action` is the rule's declared action
/// regardless (callers decide how to apply it based on `passed`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RuleOutcome {
pub rule_id: String,
pub passed: bool,
pub action: RuleAction,
}
fn evaluate_condition(cond: &RuleCondition, ctx: &serde_json::Value) -> bool {
match cond {
RuleCondition::Always => true,
RuleCondition::FieldEquals { field, value } => {
field_as_string(ctx, field)
.map(|s| s == *value)
.unwrap_or(false)
}
RuleCondition::FieldMismatch { field, value } => {
field_as_string(ctx, field)
.map(|s| s != *value)
.unwrap_or(false)
}
RuleCondition::FieldEmpty { field } => {
match lookup(ctx, field) {
None => true,
Some(v) => v.is_null() || v.as_str().map(|s| s.is_empty()).unwrap_or(false),
}
}
RuleCondition::FieldGreater { field, threshold } => {
lookup(ctx, field)
.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64)))
.map(|n| n > *threshold)
.unwrap_or(false)
}
RuleCondition::FieldContainsAny { field, needles } => {
match field_as_string(ctx, field) {
None => false,
Some(s) => {
let haystack = s.to_ascii_lowercase();
needles.iter().any(|n| haystack.contains(&n.to_ascii_lowercase()))
}
}
}
}
}
/// Walk a dot-separated path through a serde_json::Value. `"worker.status"`
/// → `ctx["worker"]["status"]`. Returns None if any segment is missing or
/// a non-object is encountered mid-path.
fn lookup<'a>(ctx: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
let mut cur = ctx;
for seg in path.split('.') {
cur = cur.get(seg)?;
}
Some(cur)
}
fn field_as_string(ctx: &serde_json::Value, path: &str) -> Option<String> {
lookup(ctx, path).and_then(|v| match v {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Bool(b) => Some(b.to_string()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
})
}
/// Minimal SQL guard — rejects destructive verbs (DROP/TRUNCATE/DELETE).
/// queryd/src/service.rs loads this into its `QueryState` and evaluates
/// every `/sql` request against it before hitting the DataFusion engine.
/// This is the P42-002 enforcement point flagged across scrum iters 3-5
/// ("raw SQL forwarded without schema or policy gate").
///
/// Intentionally narrow: it's a safety net, not a full SQL parser. If
/// callers need richer AST-aware enforcement they should extend this with
/// structured rules rather than new needles.
pub fn sql_query_guard_store() -> TruthStore {
let mut store = TruthStore::new();
store.add_rule(TruthRule {
id: "no-destructive-sql".to_string(),
task_class: "sql_query".to_string(),
description: "SQL must not contain destructive verbs".to_string(),
condition: RuleCondition::FieldContainsAny {
field: "sql".to_string(),
needles: vec![
"drop table".to_string(),
"drop schema".to_string(),
"drop database".to_string(),
"truncate".to_string(),
"delete from".to_string(),
],
},
action: RuleAction::Reject {
message: "destructive SQL rejected by truth.sql_query_guard".to_string(),
},
});
store.add_rule(TruthRule {
id: "sql-not-empty".to_string(),
task_class: "sql_query".to_string(),
description: "SQL must not be empty".to_string(),
condition: RuleCondition::FieldEmpty {
field: "sql".to_string(),
},
action: RuleAction::Reject {
message: "empty SQL rejected".to_string(),
},
});
store
}
/// Phase 42 default store: staffing rules + DevOps scaffold composed
/// onto an empty TruthStore. Per the PRD: "Staffing rules ship first;
/// Terraform/Ansible rule shapes are scaffolded but unpopulated until
/// the long-horizon phase." The composition order is irrelevant here
/// (DevOps is empty) but preserved so the shape matches the PRD's
/// expected "compose on top" pattern.
///
/// Moved out of inline in-function rule registration (2026-04-24) to
/// land the Phase 42 module split the PRD called for: `staffing.rs` +
/// `devops.rs` each owns their task-class rule sets. Behavior unchanged
/// for existing callers.
pub fn default_truth_store() -> TruthStore {
devops::devops_rules(staffing::staffing_rules(TruthStore::new()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truth_store_new_is_empty() {
let store = TruthStore::new();
assert!(store.rules.is_empty());
}
#[test]
fn add_rule_inserts_into_correct_task_class() {
let mut store = TruthStore::new();
store.add_rule(TruthRule {
id: "test-rule".to_string(),
task_class: "test.task".to_string(),
description: "Test rule".to_string(),
condition: RuleCondition::Always,
action: RuleAction::Pass,
});
let rules = store.get_rules("test.task");
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].id, "test-rule");
}
#[test]
fn get_rules_returns_empty_for_unknown_class() {
let store = TruthStore::new();
let rules = store.get_rules("unknown.class");
assert!(rules.is_empty());
}
#[test]
fn check_returns_actions_for_task_class() {
let mut store = TruthStore::new();
store.add_rule(TruthRule {
id: "a1".to_string(),
task_class: "test".to_string(),
description: "A1".to_string(),
condition: RuleCondition::Always,
action: RuleAction::Pass,
});
store.add_rule(TruthRule {
id: "a2".to_string(),
task_class: "test".to_string(),
description: "A2".to_string(),
condition: RuleCondition::Always,
action: RuleAction::Reject {
message: "test reject".to_string(),
},
});
let actions = store.check("test");
assert_eq!(actions.len(), 2);
}
#[test]
fn rule_condition_serialize_always() {
let cond = RuleCondition::Always;
let json = serde_json::to_string(&cond).unwrap();
assert!(json.contains(r#""type":"Always"#));
}
#[test]
fn rule_condition_serialize_field_equals() {
let cond = RuleCondition::FieldEquals {
field: "foo".to_string(),
value: "bar".to_string(),
};
let json = serde_json::to_string(&cond).unwrap();
assert!(json.contains(r#""type":"FieldEquals""#));
assert!(json.contains(r#""field":"foo""#));
assert!(json.contains(r#""value":"bar""#));
}
#[test]
fn rule_action_serialize_redact() {
let action = RuleAction::Redact {
fields: vec!["ssn".to_string()],
};
let json = serde_json::to_string(&action).unwrap();
assert!(json.contains(r#""type":"Redact""#));
assert!(json.contains("ssn"));
}
#[test]
fn rule_action_serialize_reject() {
let action = RuleAction::Reject {
message: "test".to_string(),
};
let json = serde_json::to_string(&action).unwrap();
assert!(json.contains(r#""type":"Reject""#));
}
#[test]
fn default_truth_store_has_staffing_rules() {
let store = default_truth_store();
let fill_rules = store.get_rules("staffing.fill");
assert!(!fill_rules.is_empty());
let any_rules = store.get_rules("staffing.any");
assert!(!any_rules.is_empty());
}
#[test]
fn multiple_rules_same_task_class() {
let mut store = TruthStore::new();
for i in 0..5 {
store.add_rule(TruthRule {
id: format!("rule-{}", i),
task_class: "test".to_string(),
description: format!("Rule {}", i),
condition: RuleCondition::Always,
action: RuleAction::Pass,
});
}
let rules = store.get_rules("test");
assert_eq!(rules.len(), 5);
}
#[test]
fn truth_rule_clone_preserves_data() {
let rule = TruthRule {
id: "clone-test".to_string(),
task_class: "clone.task".to_string(),
description: "Clone test".to_string(),
condition: RuleCondition::FieldEquals {
field: "x".to_string(),
value: "y".to_string(),
},
action: RuleAction::Block {
message: "blocked".to_string(),
},
};
let cloned = rule.clone();
assert_eq!(cloned.id, rule.id);
assert_eq!(cloned.condition, rule.condition);
assert_eq!(cloned.action, rule.action);
}
#[test]
fn field_greater_condition_parse() {
let json = r#"{"type":"FieldGreater","field":"count","threshold":10}"#;
let cond: RuleCondition = serde_json::from_str(json).unwrap();
match cond {
RuleCondition::FieldGreater { field, threshold } => {
assert_eq!(field, "count");
assert_eq!(threshold, 10);
}
_ => panic!("Expected FieldGreater"),
}
}
#[test]
fn block_action_blocks_with_message() {
let action = RuleAction::Block {
message: "Rate limited".to_string(),
};
let json = serde_json::to_string(&action).unwrap();
assert!(json.contains("Rate limited"));
}
#[test]
fn empty_store_check_returns_empty() {
let store = TruthStore::new();
let actions = store.check("empty.class");
assert!(actions.is_empty());
}
// ── P42-001 evaluate() tests — actually walk RuleCondition ──
fn fill_store() -> TruthStore {
let mut s = TruthStore::new();
s.add_rule(TruthRule {
id: "active".into(),
task_class: "t".into(),
description: "must be active".into(),
condition: RuleCondition::FieldEquals {
field: "worker.status".into(),
value: "active".into(),
},
action: RuleAction::Reject {
message: "worker not active".into(),
},
});
s.add_rule(TruthRule {
id: "deadline".into(),
task_class: "t".into(),
description: "deadline required".into(),
condition: RuleCondition::FieldEmpty {
field: "contract.deadline".into(),
},
action: RuleAction::Reject {
message: "missing deadline".into(),
},
});
s.add_rule(TruthRule {
id: "budget".into(),
task_class: "t".into(),
description: "budget positive".into(),
condition: RuleCondition::FieldGreater {
field: "contract.budget".into(),
threshold: 0,
},
action: RuleAction::Block {
message: "budget must be positive".into(),
},
});
s
}
#[test]
fn evaluate_field_equals_pass_on_match() {
let s = fill_store();
let ctx = serde_json::json!({"worker": {"status": "active"}});
let o = s.evaluate("t", &ctx);
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
assert!(active.passed, "active condition should hold");
}
#[test]
fn evaluate_field_equals_fail_on_mismatch() {
let s = fill_store();
let ctx = serde_json::json!({"worker": {"status": "terminated"}});
let o = s.evaluate("t", &ctx);
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
assert!(!active.passed, "terminated should fail active condition");
}
#[test]
fn evaluate_field_equals_fail_on_missing() {
let s = fill_store();
let ctx = serde_json::json!({});
let o = s.evaluate("t", &ctx);
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
assert!(!active.passed, "missing worker.status should fail");
}
#[test]
fn evaluate_field_empty_pass_when_absent() {
let s = fill_store();
// FieldEmpty passes when the field is missing/null/empty string.
// Deadline rule says "field empty means action fires" — so passed=true
// here means the rule's condition held (deadline IS empty).
let ctx = serde_json::json!({});
let o = s.evaluate("t", &ctx);
let deadline = o.iter().find(|r| r.rule_id == "deadline").unwrap();
assert!(deadline.passed);
}
#[test]
fn evaluate_field_empty_fail_when_present() {
let s = fill_store();
let ctx = serde_json::json!({"contract": {"deadline": "2026-05-01"}});
let o = s.evaluate("t", &ctx);
let deadline = o.iter().find(|r| r.rule_id == "deadline").unwrap();
assert!(!deadline.passed, "non-empty deadline should fail FieldEmpty check");
}
#[test]
fn evaluate_field_greater_pass_and_fail() {
let s = fill_store();
let ctx_ok = serde_json::json!({"contract": {"budget": 100}});
let ctx_bad = serde_json::json!({"contract": {"budget": 0}});
let ok = s.evaluate("t", &ctx_ok);
let bad = s.evaluate("t", &ctx_bad);
assert!(ok.iter().find(|r| r.rule_id == "budget").unwrap().passed);
assert!(!bad.iter().find(|r| r.rule_id == "budget").unwrap().passed);
}
#[test]
fn evaluate_always_condition_passes_unconditionally() {
let mut s = TruthStore::new();
s.add_rule(TruthRule {
id: "always".into(),
task_class: "x".into(),
description: "".into(),
condition: RuleCondition::Always,
action: RuleAction::Pass,
});
let o = s.evaluate("x", &serde_json::json!(null));
assert!(o[0].passed);
}
#[test]
fn evaluate_preserves_action_regardless_of_outcome() {
let s = fill_store();
let ctx = serde_json::json!({"worker": {"status": "active"}});
let o = s.evaluate("t", &ctx);
let active = o.iter().find(|r| r.rule_id == "active").unwrap();
// Action is attached whether the rule passed or not — the consumer
// decides how to use it.
assert_eq!(
active.action,
RuleAction::Reject {
message: "worker not active".into()
}
);
}
#[test]
fn evaluate_on_unknown_task_class_returns_empty() {
let s = fill_store();
let o = s.evaluate("nonexistent", &serde_json::json!({}));
assert!(o.is_empty());
}
#[test]
fn check_still_returns_actions_unconditionally_for_back_compat() {
// Legacy API should still behave the same — no condition walking.
let s = fill_store();
let actions = s.check("t");
assert_eq!(actions.len(), 3, "check returns one action per rule regardless of condition");
}
fn sql_guard_store() -> TruthStore {
let mut s = TruthStore::new();
s.add_rule(TruthRule {
id: "no-destructive".into(),
task_class: "sql_query".into(),
description: "SQL must not contain destructive verbs".into(),
condition: RuleCondition::FieldContainsAny {
field: "sql".into(),
needles: vec![
"drop table".into(),
"drop schema".into(),
"truncate".into(),
"delete from".into(),
],
},
action: RuleAction::Reject {
message: "destructive SQL rejected".into(),
},
});
s
}
#[test]
fn field_contains_any_matches_case_insensitively() {
let s = sql_guard_store();
let ctx = serde_json::json!({"sql": "SELECT * FROM t; DROP TABLE users;"});
let o = s.evaluate("sql_query", &ctx);
assert!(o[0].passed, "condition holds when needle present (case-insensitive)");
}
#[test]
fn field_contains_any_is_false_when_no_needle_matches() {
let s = sql_guard_store();
let ctx = serde_json::json!({"sql": "SELECT count(*) FROM workers"});
let o = s.evaluate("sql_query", &ctx);
assert!(!o[0].passed, "benign SELECT should not match destructive needles");
}
#[test]
fn field_contains_any_false_when_field_missing() {
let s = sql_guard_store();
let ctx = serde_json::json!({});
let o = s.evaluate("sql_query", &ctx);
assert!(!o[0].passed, "missing field → condition cannot hold");
}
#[test]
fn field_contains_any_empty_needles_list_never_matches() {
let mut s = TruthStore::new();
s.add_rule(TruthRule {
id: "empty".into(),
task_class: "x".into(),
description: "".into(),
condition: RuleCondition::FieldContainsAny {
field: "sql".into(),
needles: vec![],
},
action: RuleAction::Pass,
});
let o = s.evaluate("x", &serde_json::json!({"sql": "anything"}));
assert!(!o[0].passed, "no needles → any::<bool> is false");
}
}
-187
View File
@@ -1,187 +0,0 @@
//! File-backed TruthRule loader (Phase 42 PRD).
//!
//! PRD: "truth/ dir at repo root — rule files, versioned in git."
//! This module walks a directory, parses every `*.toml` file it finds,
//! and registers the rules into a caller-supplied store. Rule IDs must
//! be unique across the combined set — duplicate-ID collisions are
//! load-time errors.
//!
//! The TOML format matches the shape at `truth/README.md`. The same
//! `RuleCondition` + `RuleAction` enums used by the in-code registrars
//! deserialize directly from `condition = { type = "FieldEquals", ... }`
//! thanks to `#[serde(tag = "type")]`.
use std::fs;
use std::path::Path;
use serde::Deserialize;
use crate::{TruthRule, TruthStore};
/// Deserialization wrapper — a TOML file is a list of [[rule]] blocks.
#[derive(Deserialize)]
struct RuleFile {
#[serde(default)]
rule: Vec<TruthRule>,
}
/// Load every `*.toml` file in `dir` and add its rules to `store`.
/// Returns the number of rules loaded across all files.
///
/// Errors:
/// - directory doesn't exist or can't be read
/// - any `.toml` file fails to parse
/// - any rule ID collides with an existing rule (same ID already
/// registered in the store)
///
/// Non-goals: recursive walk (flat dir only), hot reload (one-shot load).
pub fn load_from_dir(store: &mut TruthStore, dir: impl AsRef<Path>) -> Result<usize, String> {
let dir = dir.as_ref();
let entries = fs::read_dir(dir)
.map_err(|e| format!("read_dir {}: {e}", dir.display()))?;
let mut loaded_ids = store.all_rule_ids();
let mut count = 0usize;
let mut paths: Vec<_> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().and_then(|s| s.to_str()) == Some("toml"))
.collect();
// Deterministic order — alphabetical by filename. Matters when a
// cross-file ID collision happens; the earlier filename wins
// nothing (both error), but the error message is reproducible.
paths.sort();
for path in paths {
let raw = fs::read_to_string(&path)
.map_err(|e| format!("read {}: {e}", path.display()))?;
let file: RuleFile = toml::from_str(&raw)
.map_err(|e| format!("parse {}: {e}", path.display()))?;
for rule in file.rule {
if !loaded_ids.insert(rule.id.clone()) {
return Err(format!(
"duplicate rule id '{}' from {}",
rule.id,
path.display()
));
}
store.add_rule(rule);
count += 1;
}
}
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_file(dir: &Path, name: &str, content: &str) {
let path = dir.join(name);
let mut f = fs::File::create(&path).unwrap();
f.write_all(content.as_bytes()).unwrap();
}
#[test]
fn loads_rules_from_toml_files() {
let tmp = tempdir_for("loader_test");
write_file(&tmp, "a.toml", r#"
[[rule]]
id = "a-rule"
task_class = "test"
description = "test rule"
action = { type = "Pass" }
[rule.condition]
type = "Always"
"#);
let mut store = TruthStore::new();
let n = load_from_dir(&mut store, &tmp).unwrap();
assert_eq!(n, 1);
assert_eq!(store.get_rules("test").len(), 1);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn rejects_duplicate_rule_ids() {
let tmp = tempdir_for("dup_ids");
write_file(&tmp, "a.toml", r#"
[[rule]]
id = "same"
task_class = "t"
description = ""
action = { type = "Pass" }
[rule.condition]
type = "Always"
"#);
write_file(&tmp, "b.toml", r#"
[[rule]]
id = "same"
task_class = "t"
description = ""
action = { type = "Pass" }
[rule.condition]
type = "Always"
"#);
let mut store = TruthStore::new();
let err = load_from_dir(&mut store, &tmp).unwrap_err();
assert!(err.contains("duplicate"), "got: {err}");
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn duplicate_with_in_code_rule_is_rejected() {
// Existing in-store IDs count as "already registered." Operator
// can't shadow an in-code rule by file without changing the ID.
let tmp = tempdir_for("dup_in_code");
write_file(&tmp, "conflict.toml", r#"
[[rule]]
id = "worker-active"
task_class = "staffing.fill"
description = "file attempt"
action = { type = "Pass" }
[rule.condition]
type = "Always"
"#);
// staffing_rules registers "worker-active"
let mut store = crate::staffing::staffing_rules(TruthStore::new());
let err = load_from_dir(&mut store, &tmp).unwrap_err();
assert!(err.contains("duplicate") && err.contains("worker-active"));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn skips_non_toml_files() {
let tmp = tempdir_for("skip_non_toml");
write_file(&tmp, "a.toml", r#"
[[rule]]
id = "x"
task_class = "t"
description = ""
action = { type = "Pass" }
[rule.condition]
type = "Always"
"#);
write_file(&tmp, "README.md", "not a toml file");
let mut store = TruthStore::new();
let n = load_from_dir(&mut store, &tmp).unwrap();
assert_eq!(n, 1); // README.md ignored
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn missing_dir_returns_error() {
let mut store = TruthStore::new();
let err = load_from_dir(&mut store, "/nonexistent/path/here").unwrap_err();
assert!(err.contains("read_dir"));
}
fn tempdir_for(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("truth_loader_{}_{}", tag,
std::process::id()));
fs::create_dir_all(&dir).unwrap();
dir
}
}
-125
View File
@@ -1,125 +0,0 @@
//! Staffing task-class rules for the TruthStore.
//!
//! Phase 42 PRD: "Staffing rules ship first. Terraform/Ansible rule
//! shapes are scaffolded but unpopulated until the long-horizon phase."
//! This module owns the staffing rule set; `devops.rs` holds the
//! matching scaffold for the DevOps long-horizon.
//!
//! Rules registered here live under the task classes `staffing.fill`
//! (fill proposals), `staffing.rescue` (rescue escalations), and
//! `staffing.any` (rules that apply across all staffing task classes —
//! PII redaction being the canonical example).
//!
//! All rules are evaluated via the `TruthStore::evaluate` walk, which
//! pairs each rule's `RuleCondition` against a caller-supplied JSON
//! context and emits a `RuleOutcome { passed, action }` per rule.
//! Downstream enforcement (router gate, SQL gate, execution-loop gate)
//! decides how to apply the action — `Reject` / `Block` shortcircuit,
//! `Redact` mutates, `Pass` is informational.
use crate::{RuleAction, RuleCondition, TruthRule, TruthStore};
/// Register the staffing rule set on an existing store. Returns the
/// store for chaining if the caller wants to fold other rule sets on
/// top (e.g. `staffing_rules(devops_rules(TruthStore::new()))`).
pub fn staffing_rules(mut store: TruthStore) -> TruthStore {
store.add_rule(TruthRule {
id: "worker-active".to_string(),
task_class: "staffing.fill".to_string(),
description: "Worker must be active".to_string(),
condition: RuleCondition::FieldEquals {
field: "worker.status".to_string(),
value: "active".to_string(),
},
action: RuleAction::Pass,
});
store.add_rule(TruthRule {
id: "client-not-blacklisted".to_string(),
task_class: "staffing.fill".to_string(),
description: "Worker cannot be blacklisted for client".to_string(),
condition: RuleCondition::FieldEquals {
field: "worker.client_blacklisted".to_string(),
value: "false".to_string(),
},
action: RuleAction::Pass,
});
store.add_rule(TruthRule {
id: "deadline-required".to_string(),
task_class: "staffing.fill".to_string(),
description: "Contract must have deadline".to_string(),
condition: RuleCondition::FieldEmpty {
field: "contract.deadline".to_string(),
},
action: RuleAction::Reject {
message: "Contract deadline is required".to_string(),
},
});
store.add_rule(TruthRule {
id: "budget-required".to_string(),
task_class: "staffing.fill".to_string(),
description: "Budget must be non-negative".to_string(),
condition: RuleCondition::FieldGreater {
field: "contract.budget_per_hour_max".to_string(),
threshold: 0,
},
action: RuleAction::Pass,
});
store.add_rule(TruthRule {
id: "pii-redact".to_string(),
task_class: "staffing.any".to_string(),
description: "Redact PII before cloud calls".to_string(),
condition: RuleCondition::Always,
action: RuleAction::Redact {
fields: vec!["ssn".to_string(), "salary".to_string()],
},
});
store
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn staffing_rules_registers_five_rules() {
// 4 staffing.fill rules + 1 staffing.any rule = 5 total.
// Regression guard: if someone adds a rule to this module
// without updating the count, this test surfaces it.
let store = staffing_rules(TruthStore::new());
let fill = store.get_rules("staffing.fill").len();
let any = store.get_rules("staffing.any").len();
assert_eq!(fill, 4);
assert_eq!(any, 1);
}
#[test]
fn blacklisted_worker_fails_the_rule() {
let store = staffing_rules(TruthStore::new());
let ctx = serde_json::json!({
"worker": { "client_blacklisted": "true" }
});
let outcomes = store.evaluate("staffing.fill", &ctx);
let blk = outcomes.iter().find(|o| o.rule_id == "client-not-blacklisted").unwrap();
assert!(!blk.passed, "blacklisted worker must fail the rule");
}
#[test]
fn missing_deadline_fires_reject_via_empty_condition() {
let store = staffing_rules(TruthStore::new());
// FieldEmpty passes when the field is missing — and the rule's
// action is Reject, so enforcement should fire.
let ctx = serde_json::json!({});
let outcomes = store.evaluate("staffing.fill", &ctx);
let deadline = outcomes.iter().find(|o| o.rule_id == "deadline-required").unwrap();
assert!(deadline.passed);
match &deadline.action {
RuleAction::Reject { message } => assert!(message.contains("deadline")),
_ => panic!("expected Reject action"),
}
}
}
+2 -2
View File
@@ -1238,13 +1238,13 @@ fn IngestPanel() -> Element {
pg_tables.set(Some(tables));
}
}
Err(_) => pg_tables.set(None),
Err(e) => pg_tables.set(None),
}
pg_loading.set(false);
});
};
let import_table = move |table: String| {
let mut import_table = move |table: String| {
let host = pg_host.read().clone();
let db = pg_db.read().clone();
spawn(async move {
-11
View File
@@ -1,11 +0,0 @@
[package]
name = "validator"
version = "0.1.0"
edition = "2024"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
-44
View File
@@ -1,44 +0,0 @@
//! DevOps validator scaffold — long-horizon.
//!
//! PRD: "scaffold only: stubbed Terraform/Ansible validators
//! (`terraform validate`, `ansible-lint`) for the long-horizon phase."
//! Shipped as Unimplemented stubs so the execution-loop dispatcher
//! has a consistent failure shape to surface ("phase 43 not wired")
//! instead of a missing-impl panic.
use crate::{Artifact, Report, Validator, ValidationError};
pub struct TerraformValidator;
impl Validator for TerraformValidator {
fn name(&self) -> &'static str { "devops.terraform" }
fn validate(&self, _artifact: &Artifact) -> Result<Report, ValidationError> {
Err(ValidationError::Unimplemented { artifact: "terraform_plan" })
}
}
pub struct AnsibleValidator;
impl Validator for AnsibleValidator {
fn name(&self) -> &'static str { "devops.ansible" }
fn validate(&self, _artifact: &Artifact) -> Result<Report, ValidationError> {
Err(ValidationError::Unimplemented { artifact: "ansible_playbook" })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn terraform_scaffold_returns_unimplemented() {
let r = TerraformValidator.validate(&Artifact::TerraformPlan(serde_json::json!({})));
assert!(matches!(r, Err(ValidationError::Unimplemented { .. })));
}
#[test]
fn ansible_scaffold_returns_unimplemented() {
let r = AnsibleValidator.validate(&Artifact::AnsiblePlaybook(serde_json::json!({})));
assert!(matches!(r, Err(ValidationError::Unimplemented { .. })));
}
}
-95
View File
@@ -1,95 +0,0 @@
//! Phase 43 Validation Pipeline.
//!
//! PRD: "Staffing outputs run through schema / completeness /
//! consistency / policy gates. Plug into Layer 5 execution loop —
//! failure triggers observer-correction iteration."
//!
//! This crate provides the `Validator` trait + `Artifact` enum +
//! Report/ValidationError types. Staffing validators (fill, email,
//! playbook) and the DevOps scaffold live in submodules.
//!
//! Landed 2026-04-24 as a scaffold — the trait + types + module
//! layout match the PRD; individual validator implementations are
//! `Unimplemented` stubs that return a clear "phase 43 not wired"
//! error rather than silently passing. The execution-loop integration
//! (generate → validate → correct → retry) comes in a follow-up
//! commit once the stubs are filled.
use serde::{Deserialize, Serialize};
use thiserror::Error;
pub mod staffing;
pub mod devops;
/// What a validator saw. One variant per artifact class we validate.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum Artifact {
/// A fill proposal from the staffing executor — shape is
/// `{fills: [{candidate_id, name}]}` per PRD.
FillProposal(serde_json::Value),
/// An email/SMS draft for outreach.
EmailDraft(serde_json::Value),
/// A playbook being sealed for memory.
Playbook(serde_json::Value),
/// Terraform plan output (scaffold, long-horizon).
TerraformPlan(serde_json::Value),
/// Ansible playbook (scaffold, long-horizon).
AnsiblePlaybook(serde_json::Value),
}
/// Success report. Empty `findings` means a clean pass. Populated
/// findings with `Severity::Warning` means "acceptable but notable" —
/// the artifact passes. `Severity::Error` means validation failed;
/// the validator should return `Err(...)` in that case, not `Ok`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Report {
pub findings: Vec<Finding>,
pub elapsed_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Finding {
pub field: String,
pub severity: Severity,
pub message: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Severity {
Warning,
Error,
}
/// Validation failure — what went wrong + where + why. Returned as
/// `Err` from `validate`. Execution loop catches these and feeds them
/// to the observer-correction retry loop.
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
pub enum ValidationError {
/// Artifact schema doesn't match what we expected.
#[error("schema mismatch at {field}: {reason}")]
Schema { field: String, reason: String },
/// Required data missing (e.g. endorsed count != target count).
#[error("completeness: {reason}")]
Completeness { reason: String },
/// Data that's inconsistent with another source of truth
/// (e.g. worker_id doesn't exist in the workers table).
#[error("consistency: {reason}")]
Consistency { reason: String },
/// Policy violation — truth rule or access control said no.
#[error("policy: {reason}")]
Policy { reason: String },
/// Validator hasn't been implemented yet — scaffold stub.
#[error("validator not yet implemented for {artifact} — phase 43 scaffold")]
Unimplemented { artifact: &'static str },
}
/// Core validation contract. Implementations live in `staffing::*` and
/// `devops::*`. The execution loop dispatches to the right impl based
/// on the Artifact variant.
pub trait Validator: Send + Sync {
fn validate(&self, artifact: &Artifact) -> Result<Report, ValidationError>;
/// Human-readable name for logs + Langfuse traces.
fn name(&self) -> &'static str;
}
-116
View File
@@ -1,116 +0,0 @@
//! Email/SMS draft validator.
//!
//! PRD checks:
//! - Schema (TO/BODY fields present)
//! - Length (SMS ≤ 160 chars; email subject ≤ 78 chars)
//! - PII absence (no SSN / salary leaked into outgoing text)
//! - Worker-name consistency (name in message matches worker record)
//!
//! Scaffold implements schema + length. PII regex (SSN pattern,
//! salary-number pattern) lives in `shared::pii::strip_pii` — plug in
//! a follow-up when the validator caller knows the worker record to
//! cross-check name consistency.
use crate::{Artifact, Report, Validator, ValidationError};
use std::time::Instant;
pub struct EmailValidator;
const SMS_MAX_CHARS: usize = 160;
const EMAIL_SUBJECT_MAX_CHARS: usize = 78;
impl Validator for EmailValidator {
fn name(&self) -> &'static str { "staffing.email" }
fn validate(&self, artifact: &Artifact) -> Result<Report, ValidationError> {
let started = Instant::now();
let value = match artifact {
Artifact::EmailDraft(v) => v,
other => return Err(ValidationError::Schema {
field: "artifact".into(),
reason: format!("EmailValidator expects EmailDraft, got {other:?}"),
}),
};
let to = value.get("to").and_then(|v| v.as_str()).ok_or(
ValidationError::Schema {
field: "to".into(),
reason: "missing or not a string".into(),
},
)?;
let body = value.get("body").and_then(|v| v.as_str()).ok_or(
ValidationError::Schema {
field: "body".into(),
reason: "missing or not a string".into(),
},
)?;
let is_sms = value.get("kind").and_then(|k| k.as_str()) == Some("sms");
if is_sms && body.len() > SMS_MAX_CHARS {
return Err(ValidationError::Completeness {
reason: format!("SMS body is {} chars, max {SMS_MAX_CHARS}", body.len()),
});
}
if let Some(subject) = value.get("subject").and_then(|v| v.as_str()) {
if subject.len() > EMAIL_SUBJECT_MAX_CHARS {
return Err(ValidationError::Completeness {
reason: format!(
"email subject is {} chars, max {EMAIL_SUBJECT_MAX_CHARS}",
subject.len()
),
});
}
}
let _ = to; // touched for future name-consistency check
// TODO(phase-43 v2): PII scan + worker-name consistency.
Ok(Report {
findings: vec![],
elapsed_ms: started.elapsed().as_millis() as u64,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn long_sms_fails_completeness() {
let body = "x".repeat(200);
let r = EmailValidator.validate(&Artifact::EmailDraft(serde_json::json!({
"to": "+15555550123",
"body": body,
"kind": "sms"
})));
assert!(matches!(r, Err(ValidationError::Completeness { .. })));
}
#[test]
fn long_email_subject_fails_completeness() {
let r = EmailValidator.validate(&Artifact::EmailDraft(serde_json::json!({
"to": "a@b.com",
"body": "hi",
"subject": "x".repeat(100)
})));
assert!(matches!(r, Err(ValidationError::Completeness { .. })));
}
#[test]
fn missing_to_fails_schema() {
let r = EmailValidator.validate(&Artifact::EmailDraft(serde_json::json!({"body": "hi"})));
assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field == "to"));
}
#[test]
fn well_formed_email_passes() {
let r = EmailValidator.validate(&Artifact::EmailDraft(serde_json::json!({
"to": "hiring@example.com",
"subject": "Interview: Friday 10am",
"body": "Hi Jane — confirming interview Friday 10am."
})));
assert!(r.is_ok(), "well-formed email should pass: {:?}", r);
}
}
-103
View File
@@ -1,103 +0,0 @@
//! Fill-proposal validator.
//!
//! PRD checks:
//! - Schema compliance (propose_done shape matches
//! `{fills: [{candidate_id, name}]}`)
//! - Completeness (endorsed count == target_count)
//! - Worker existence (every candidate_id present in workers_500k)
//! - Status check (active, not_on_client_blacklist)
//! - Geo/role match (worker city/state/role matches contract)
//!
//! Today this is a scaffold — schema check is real (it's cheap); the
//! worker-existence / status / geo checks need a catalog lookup and
//! land in a follow-up when the catalog query helper is wired into
//! this crate.
use crate::{Artifact, Report, Validator, ValidationError};
use std::time::Instant;
pub struct FillValidator;
impl Validator for FillValidator {
fn name(&self) -> &'static str { "staffing.fill" }
fn validate(&self, artifact: &Artifact) -> Result<Report, ValidationError> {
let started = Instant::now();
let value = match artifact {
Artifact::FillProposal(v) => v,
other => return Err(ValidationError::Schema {
field: "artifact".into(),
reason: format!("FillValidator expects FillProposal, got {other:?}"),
}),
};
// Schema check — the only real validation shipped in this
// scaffold. Catches the common "model emitted prose instead of
// JSON" failure mode before the consistency checks even run.
let fills = value.get("fills").and_then(|f| f.as_array()).ok_or(
ValidationError::Schema {
field: "fills".into(),
reason: "expected top-level `fills` array".into(),
},
)?;
for (i, fill) in fills.iter().enumerate() {
if fill.get("candidate_id").is_none() {
return Err(ValidationError::Schema {
field: format!("fills[{i}].candidate_id"),
reason: "missing".into(),
});
}
if fill.get("name").is_none() {
return Err(ValidationError::Schema {
field: format!("fills[{i}].name"),
reason: "missing".into(),
});
}
}
// TODO(phase-43 v2): worker-existence / status / geo checks.
// Need a catalog query handle injected into FillValidator's
// constructor — out of scope for the scaffold.
Ok(Report {
findings: vec![],
elapsed_ms: started.elapsed().as_millis() as u64,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrong_artifact_type_fails_schema() {
let r = FillValidator.validate(&Artifact::EmailDraft(serde_json::json!({})));
assert!(matches!(r, Err(ValidationError::Schema { .. })));
}
#[test]
fn missing_fills_array_fails_schema() {
let r = FillValidator.validate(&Artifact::FillProposal(serde_json::json!({})));
assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field == "fills"));
}
#[test]
fn fill_without_candidate_id_fails() {
let r = FillValidator.validate(&Artifact::FillProposal(serde_json::json!({
"fills": [{"name": "Jane"}]
})));
assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field.contains("candidate_id")));
}
#[test]
fn well_formed_proposal_passes_schema() {
let r = FillValidator.validate(&Artifact::FillProposal(serde_json::json!({
"fills": [
{"candidate_id": "W-123", "name": "Jane Doe"},
{"candidate_id": "W-456", "name": "John Smith"}
]
})));
assert!(r.is_ok(), "well-formed proposal should pass schema: {:?}", r);
}
}
-8
View File
@@ -1,8 +0,0 @@
//! Staffing validators — fill proposals, email/SMS drafts, sealed
//! playbooks. Phase 43 PRD: "the 0→85% pattern reproduces on real
//! staffing tasks — the iteration loop with validation in place is
//! what made small models successful."
pub mod fill;
pub mod email;
pub mod playbook;
-134
View File
@@ -1,134 +0,0 @@
//! Sealed playbook validator.
//!
//! PRD checks:
//! - Operation format (`fill: Role xN in City, ST`)
//! - endorsed_names non-empty, ≤ target_count × 2
//! - fingerprint populated (Phase 25 validity window requirement)
use crate::{Artifact, Report, Validator, ValidationError};
use std::time::Instant;
pub struct PlaybookValidator;
impl Validator for PlaybookValidator {
fn name(&self) -> &'static str { "staffing.playbook" }
fn validate(&self, artifact: &Artifact) -> Result<Report, ValidationError> {
let started = Instant::now();
let value = match artifact {
Artifact::Playbook(v) => v,
other => return Err(ValidationError::Schema {
field: "artifact".into(),
reason: format!("PlaybookValidator expects Playbook, got {other:?}"),
}),
};
// Operation format: "fill: Role xN in City, ST" — at minimum
// we check the string-shape. Fuller grammar parse lives in
// phase 25 code where operations are structured beyond strings.
let op = value.get("operation").and_then(|v| v.as_str()).ok_or(
ValidationError::Schema {
field: "operation".into(),
reason: "missing or not a string".into(),
},
)?;
if !op.starts_with("fill:") {
return Err(ValidationError::Schema {
field: "operation".into(),
reason: format!("expected `fill: ...` prefix, got {op:?}"),
});
}
let endorsed = value.get("endorsed_names").and_then(|v| v.as_array()).ok_or(
ValidationError::Schema {
field: "endorsed_names".into(),
reason: "missing or not an array".into(),
},
)?;
if endorsed.is_empty() {
return Err(ValidationError::Completeness {
reason: "endorsed_names must be non-empty".into(),
});
}
if let Some(target) = value.get("target_count").and_then(|v| v.as_u64()) {
let max = (target * 2) as usize;
if endorsed.len() > max {
return Err(ValidationError::Completeness {
reason: format!(
"endorsed_names ({}) exceeds target_count × 2 ({max})",
endorsed.len()
),
});
}
}
if value.get("fingerprint").and_then(|v| v.as_str()).map_or(true, |s| s.is_empty()) {
return Err(ValidationError::Schema {
field: "fingerprint".into(),
reason: "missing — required for Phase 25 validity window".into(),
});
}
Ok(Report {
findings: vec![],
elapsed_ms: started.elapsed().as_millis() as u64,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn well_formed_playbook_passes() {
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
"operation": "fill: Welder x2 in Toledo, OH",
"endorsed_names": ["W-123", "W-456"],
"target_count": 2,
"fingerprint": "abc123"
})));
assert!(r.is_ok(), "got {:?}", r);
}
#[test]
fn empty_endorsed_names_fails_completeness() {
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
"operation": "fill: Welder x2 in Toledo, OH",
"endorsed_names": [],
"fingerprint": "abc"
})));
assert!(matches!(r, Err(ValidationError::Completeness { .. })));
}
#[test]
fn overfull_endorsed_names_fails_completeness() {
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
"operation": "fill: Welder x1 in Toledo, OH",
"endorsed_names": ["a", "b", "c"],
"target_count": 1,
"fingerprint": "abc"
})));
assert!(matches!(r, Err(ValidationError::Completeness { .. })));
}
#[test]
fn missing_fingerprint_fails_schema() {
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
"operation": "fill: X x1 in A, B",
"endorsed_names": ["a"]
})));
assert!(matches!(r, Err(ValidationError::Schema { field, .. }) if field == "fingerprint"));
}
#[test]
fn wrong_operation_prefix_fails_schema() {
let r = PlaybookValidator.validate(&Artifact::Playbook(serde_json::json!({
"operation": "sms_draft: hello",
"endorsed_names": ["a"],
"fingerprint": "x"
})));
assert!(matches!(r, Err(ValidationError::Schema { .. })));
}
}
-118
View File
@@ -1,118 +0,0 @@
//! Profile activation tracking (Phase 41 PRD).
//!
//! Phase 41 PRD called out `crates/vectord/src/activation.rs` with
//! `ActivationTracker` + background-job pattern. The activation
//! handler itself lives in `service.rs::activate_profile` (200+ lines
//! of warm-up + bucket binding that's wired to VectorState); this
//! module provides the type the PRD named and a single-flight guard
//! that satisfies the PRD gate "refuse new activation if one is
//! pending/running."
//!
//! Handler extraction (moving the body of `activate_profile` here)
//! is deliberately NOT in this commit — it's a module-structure
//! refactor, not a semantic change. When that lands, the inline
//! `tokio::spawn` in `service.rs` moves into `ActivationTracker::start`
//! and the HTTP handler shrinks to ~20 lines of validate + start +
//! respond-202.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
/// Tracks in-flight profile activations. The PRD's "single-flight guard"
/// lives here: callers check `is_pending` before starting a new activation
/// and register via `mark_pending` if they proceed. On completion, they
/// call `mark_complete` so the next caller can start.
///
/// Per-profile granularity — activating profile A doesn't block B.
#[derive(Clone, Default)]
pub struct ActivationTracker {
pending: Arc<RwLock<HashMap<String, String>>>, // profile_id → job_id
}
impl ActivationTracker {
pub fn new() -> Self {
Self::default()
}
/// Check if a profile has an activation already running. Returns the
/// in-flight job_id if so. Safe to call without holding a lock.
pub async fn is_pending(&self, profile_id: &str) -> Option<String> {
self.pending.read().await.get(profile_id).cloned()
}
/// Register a new activation as pending. Returns false if an
/// activation is already running for the same profile (caller should
/// return 409 Conflict or surface the existing job_id). Returns true
/// on successful registration.
pub async fn mark_pending(&self, profile_id: &str, job_id: &str) -> bool {
let mut guard = self.pending.write().await;
if guard.contains_key(profile_id) {
return false;
}
guard.insert(profile_id.to_string(), job_id.to_string());
true
}
/// Remove the pending marker when activation finishes (success OR
/// failure — both free the slot for the next caller).
pub async fn mark_complete(&self, profile_id: &str) {
self.pending.write().await.remove(profile_id);
}
/// How many activations are currently in-flight across all profiles.
pub async fn in_flight_count(&self) -> usize {
self.pending.read().await.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn empty_tracker_has_no_pending() {
let t = ActivationTracker::new();
assert_eq!(t.in_flight_count().await, 0);
assert!(t.is_pending("any-profile").await.is_none());
}
#[tokio::test]
async fn mark_pending_registers_the_job() {
let t = ActivationTracker::new();
assert!(t.mark_pending("profile-A", "job-1").await);
assert_eq!(t.in_flight_count().await, 1);
assert_eq!(t.is_pending("profile-A").await, Some("job-1".into()));
}
#[tokio::test]
async fn single_flight_guard_refuses_second_activation_same_profile() {
// PRD Phase 41 gate: "refuse new activation if one is
// pending/running." Same profile twice → second call returns
// false, caller must surface the in-flight job_id.
let t = ActivationTracker::new();
assert!(t.mark_pending("profile-A", "job-1").await);
assert!(!t.mark_pending("profile-A", "job-2").await);
// Still the first job — second registration didn't overwrite.
assert_eq!(t.is_pending("profile-A").await, Some("job-1".into()));
}
#[tokio::test]
async fn different_profiles_dont_block_each_other() {
// Per-profile granularity — activating A doesn't block B.
let t = ActivationTracker::new();
assert!(t.mark_pending("profile-A", "job-1").await);
assert!(t.mark_pending("profile-B", "job-2").await);
assert_eq!(t.in_flight_count().await, 2);
}
#[tokio::test]
async fn mark_complete_frees_the_slot() {
let t = ActivationTracker::new();
t.mark_pending("profile-A", "job-1").await;
t.mark_complete("profile-A").await;
assert_eq!(t.in_flight_count().await, 0);
// Next activation can now proceed.
assert!(t.mark_pending("profile-A", "job-2").await);
}
}
-150
View File
@@ -46,21 +46,6 @@ pub struct IndexMeta {
/// Existing indexes: "W-", "CAND-", "W500K-", etc.
#[serde(default)]
pub id_prefix: Option<String>,
/// PRD 11.3 — when this index was last searched against. `None` =
/// never used since registration (or pre-field-existed metadata).
/// Incremental re-embed walks this to skip cold indexes.
/// Scrum iter 11 flagged the missing field as a UnitMismatch
/// because callers were reading `created_at` as a proxy for
/// liveness, which conflated "built" with "used."
#[serde(default)]
pub last_used: Option<DateTime<Utc>>,
/// PRD 11.3 — SHA-256 of (sorted source file list + chunk_size +
/// overlap + model_version). Lets incremental re-embed detect
/// "no change since last build" without scanning the source
/// Parquet. None = signature not computed yet (pre-existing
/// indexes before this field landed).
#[serde(default)]
pub build_signature: Option<String>,
}
fn default_bucket() -> String { "primary".to_string() }
@@ -143,139 +128,4 @@ impl IndexRegistry {
self.indexes.write().await.remove(index_name);
Ok(())
}
/// Stamp `last_used = now()` on an index. Search handlers call this
/// on every hit so incremental re-embed (PRD 11.3) can tell live
/// indexes from cold ones. Silently no-ops if the index is unknown
/// — callers get best-effort behavior, not a 500 on a missing row.
pub async fn touch_used(&self, index_name: &str) {
if let Some(m) = self.indexes.write().await.get_mut(index_name) {
m.last_used = Some(Utc::now());
}
}
}
/// Compute a stable build_signature for PRD 11.3 incremental re-embed.
/// Hashes (sorted source file list, chunk_size, overlap, model_version)
/// so a caller can ask "has anything we built from changed?" without
/// re-scanning the source parquet. Same inputs always produce the
/// same hash.
pub fn compute_build_signature(
source_files: &[impl AsRef<str>],
chunk_size: usize,
overlap: usize,
model_version: &str,
) -> String {
use sha2::{Digest, Sha256};
let mut sorted: Vec<&str> = source_files.iter().map(|s| s.as_ref()).collect();
sorted.sort();
let mut hasher = Sha256::new();
for f in &sorted {
hasher.update(f.as_bytes());
hasher.update(b"\n");
}
hasher.update(chunk_size.to_le_bytes());
hasher.update(overlap.to_le_bytes());
hasher.update(model_version.as_bytes());
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_signature_is_deterministic() {
let sig1 = compute_build_signature(&["a.parquet", "b.parquet"], 800, 80, "v1");
let sig2 = compute_build_signature(&["a.parquet", "b.parquet"], 800, 80, "v1");
assert_eq!(sig1, sig2, "same inputs → same hash");
}
#[test]
fn build_signature_order_invariant() {
// Files get sorted internally so caller's order doesn't matter.
let sig_a = compute_build_signature(&["a.parquet", "b.parquet"], 800, 80, "v1");
let sig_b = compute_build_signature(&["b.parquet", "a.parquet"], 800, 80, "v1");
assert_eq!(sig_a, sig_b, "file list order must not affect hash");
}
#[test]
fn build_signature_changes_on_chunk_param() {
let sig_a = compute_build_signature(&["a.parquet"], 800, 80, "v1");
let sig_b = compute_build_signature(&["a.parquet"], 900, 80, "v1");
assert_ne!(sig_a, sig_b, "chunk_size change → different hash");
}
#[test]
fn build_signature_changes_on_model_version() {
let sig_a = compute_build_signature(&["a.parquet"], 800, 80, "v1");
let sig_b = compute_build_signature(&["a.parquet"], 800, 80, "v2");
assert_ne!(sig_a, sig_b, "model version change → different hash");
}
#[tokio::test]
async fn touch_used_updates_last_used() {
use object_store::memory::InMemory;
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let reg = IndexRegistry::new(store);
let meta = IndexMeta {
index_name: "test".into(),
source: "s".into(),
model_name: "m".into(),
model_version: "v1".into(),
dimensions: 768,
chunk_count: 0,
doc_count: 0,
chunk_size: 800,
overlap: 80,
storage_key: "k".into(),
created_at: Utc::now(),
build_time_secs: 0.0,
chunks_per_sec: 0.0,
bucket: "primary".into(),
vector_backend: Default::default(),
id_prefix: None,
last_used: None,
build_signature: None,
};
reg.register(meta).await.unwrap();
assert!(reg.get("test").await.unwrap().last_used.is_none());
reg.touch_used("test").await;
assert!(reg.get("test").await.unwrap().last_used.is_some());
}
#[tokio::test]
async fn touch_used_is_noop_on_missing_index() {
use object_store::memory::InMemory;
let store: Arc<dyn ObjectStore> = Arc::new(InMemory::new());
let reg = IndexRegistry::new(store);
// No panic — unknown index just doesn't get touched.
reg.touch_used("nonexistent").await;
}
#[test]
fn index_meta_deserializes_without_new_fields_backcompat() {
// Pre-field-existence metadata files on disk must still load.
// Critical — we have ~40 .json meta files under vectors/meta/
// that predate these fields.
let json = r#"{
"index_name": "resumes_v1",
"source": "resumes",
"model_name": "nomic-embed-text",
"model_version": "latest",
"dimensions": 768,
"chunk_count": 100,
"doc_count": 10,
"chunk_size": 800,
"overlap": 80,
"storage_key": "vectors/resumes_v1.parquet",
"created_at": "2026-04-20T00:00:00Z",
"build_time_secs": 1.0,
"chunks_per_sec": 100.0
}"#;
let meta: IndexMeta = serde_json::from_str(json).expect("must deserialize pre-field meta");
assert!(meta.last_used.is_none());
assert!(meta.build_signature.is_none());
assert_eq!(meta.bucket, "primary");
}
}
+39 -66
View File
@@ -8,13 +8,6 @@ use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum JobType {
Embed,
ProfileActivation,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum JobStatus {
@@ -26,61 +19,24 @@ pub enum JobStatus {
#[derive(Debug, Clone, Serialize)]
pub struct Job {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub job_type: Option<JobType>,
pub status: JobStatus,
pub index_name: Option<String>,
pub profile_id: Option<String>,
pub index_name: String,
pub total_chunks: usize,
/// How many chunks have been embedded so far.
/// Also serialized as "processed" for backward-compat with monitoring scripts.
pub embedded_chunks: usize,
#[serde(rename = "processed")]
pub processed_alias: usize,
pub total: usize,
pub progress_pct: f32,
pub storage_key: Option<String>,
pub error: Option<String>,
pub started_at: String,
pub completed_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
}
impl Job {
pub fn new_embed(index_name: &str, total_chunks: usize) -> Self {
Self {
id: format!("job-{}", chrono::Utc::now().timestamp_millis()),
job_type: Some(JobType::Embed),
status: JobStatus::Running,
index_name: Some(index_name.to_string()),
profile_id: None,
total_chunks,
processed_alias: 0,
progress_pct: 0.0,
storage_key: None,
error: None,
started_at: chrono::Utc::now().to_rfc3339(),
completed_at: None,
result: None,
}
}
pub fn new_profile_activation(profile_id: &str) -> Self {
Self {
id: format!("job-{}", chrono::Utc::now().timestamp_millis()),
job_type: Some(JobType::ProfileActivation),
status: JobStatus::Running,
index_name: None,
profile_id: Some(profile_id.to_string()),
total_chunks: 0,
processed_alias: 0,
progress_pct: 0.0,
storage_key: None,
error: None,
started_at: chrono::Utc::now().to_rfc3339(),
completed_at: None,
result: None,
}
}
pub chunks_per_sec: f32,
}
/// Shared progress tracker that background tasks update.
#[derive(Clone)]
pub struct JobTracker {
jobs: Arc<RwLock<HashMap<String, Job>>>,
@@ -93,42 +49,57 @@ impl JobTracker {
}
}
pub async fn create_embed(&self, index_name: &str, total_chunks: usize) -> String {
let job = Job::new_embed(index_name, total_chunks);
let id = job.id.clone();
/// Register a new job. Returns the job ID.
pub async fn create(&self, index_name: &str, total_chunks: usize) -> String {
let id = format!("job-{}", chrono::Utc::now().timestamp_millis());
let job = Job {
id: id.clone(),
status: JobStatus::Running,
index_name: index_name.to_string(),
total_chunks,
embedded_chunks: 0,
processed_alias: 0,
total: total_chunks,
progress_pct: 0.0,
storage_key: None,
error: None,
started_at: chrono::Utc::now().to_rfc3339(),
completed_at: None,
chunks_per_sec: 0.0,
};
self.jobs.write().await.insert(id.clone(), job);
id
}
pub async fn create_profile_activation(&self, profile_id: &str) -> String {
let job = Job::new_profile_activation(profile_id);
let id = job.id.clone();
self.jobs.write().await.insert(id.clone(), job);
id
}
pub async fn update_embed_progress(&self, id: &str, embedded: usize, _rate: f32) {
/// Update progress.
pub async fn update_progress(&self, id: &str, embedded: usize, rate: f32) {
let mut jobs = self.jobs.write().await;
if let Some(job) = jobs.get_mut(id) {
job.processed_alias = embedded;
job.embedded_chunks = embedded;
job.processed_alias = embedded; // keep alias in sync
job.progress_pct = if job.total_chunks > 0 {
(embedded as f32 / job.total_chunks as f32) * 100.0
} else {
0.0
};
job.chunks_per_sec = rate;
}
}
pub async fn complete(&self, id: &str, result: Option<serde_json::Value>) {
/// Mark job as completed.
pub async fn complete(&self, id: &str, storage_key: String) {
let mut jobs = self.jobs.write().await;
if let Some(job) = jobs.get_mut(id) {
job.status = JobStatus::Completed;
job.embedded_chunks = job.total_chunks;
job.processed_alias = job.total_chunks;
job.progress_pct = 100.0;
job.storage_key = Some(storage_key);
job.completed_at = Some(chrono::Utc::now().to_rfc3339());
job.result = result;
}
}
/// Mark job as failed.
pub async fn fail(&self, id: &str, error: String) {
let mut jobs = self.jobs.write().await;
if let Some(job) = jobs.get_mut(id) {
@@ -138,11 +109,13 @@ impl JobTracker {
}
}
/// Get job status.
pub async fn get(&self, id: &str) -> Option<Job> {
self.jobs.read().await.get(id).cloned()
}
/// List all jobs.
pub async fn list(&self) -> Vec<Job> {
self.jobs.read().await.values().cloned().collect()
}
}
}
-2
View File
@@ -7,9 +7,7 @@ pub mod harness;
pub mod hnsw;
pub mod index_registry;
pub mod jobs;
pub mod activation;
pub mod playbook_memory;
pub mod pathway_memory;
pub mod doc_drift;
pub mod promotion;
pub mod refresh;
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1647,7 +1647,7 @@ mod validity_window_tests {
let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
let e_expired = mkentry("pb-expired", "Nashville", "TN", None, Some(past));
let e_alive = mkentry("pb-alive", "Nashville", "TN", None, Some(future));
let e_alive = { let mut e = mkentry("pb-alive", "Nashville", "TN", None, Some(future)); e };
pm.set_entries(vec![e_expired, e_alive]).await.unwrap();
let boosts = pm.compute_boost_for_filtered_with_role(
&[1.0, 0.0, 0.0], 100, 0.5,
+4 -11
View File
@@ -131,11 +131,6 @@ impl PromotionRegistry {
file.history.drain(0..drop);
}
}
// Bind `entry` ref-captured for the log line below so the log
// doesn't double-unwrap file.current — entry is Some-by-construction
// at the function boundary; past versions reached in via
// `.as_ref().unwrap()` twice, which compiled but would panic if
// the construction above ever changed.
file.current = Some(entry);
file.index_name = index_name.to_string();
@@ -145,12 +140,10 @@ impl PromotionRegistry {
ops::put(&store, &key, json.into()).await?;
self.cache.write().await.insert(index_name.to_string(), file.clone());
if let Some(cur) = &file.current {
tracing::info!(
"promoted '{}' to config {:?} (trial={})",
index_name, cur.config, cur.trial_id,
);
}
tracing::info!(
"promoted '{}' to config {:?} (trial={})",
index_name, file.current.as_ref().unwrap().config, file.current.as_ref().unwrap().trial_id,
);
Ok(file)
}
-2
View File
@@ -308,8 +308,6 @@ async fn try_update_index_meta(
bucket: "primary".to_string(),
vector_backend: shared::types::VectorBackend::Parquet,
id_prefix: None,
last_used: None,
build_signature: None,
};
index_registry.register(meta).await
}
+251 -359
View File
@@ -7,13 +7,12 @@ use axum::{
};
use object_store::ObjectStore;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::sync::Arc;
use aibridge::client::{AiClient, EmbedRequest, GenerateRequest};
use catalogd::registry::Registry as CatalogRegistry;
use storaged::registry::BucketRegistry;
use crate::{agent, autotune, chunker, embedding_cache, harness, hnsw, index_registry, jobs, lance_backend, pathway_memory, playbook_memory, promotion, rag, refresh, search, store, supervisor, trial};
use crate::{agent, autotune, chunker, embedding_cache, harness, hnsw, index_registry, jobs, lance_backend, playbook_memory, promotion, rag, refresh, search, store, supervisor, trial};
use tokio::sync::Semaphore;
#[derive(Clone)]
@@ -55,11 +54,6 @@ pub struct VectorState {
/// and, when `use_playbook_memory` is set on /vectors/hybrid, boosts
/// workers that were actually filled in semantically-similar past ops.
pub playbook_memory: playbook_memory::PlaybookMemory,
/// Pathway memory — consensus-designed sidecar for full-context
/// backtracking + hot-swap of successful review pathways. See
/// crates/vectord/src/pathway_memory.rs for the design rationale
/// (10-probe N=3 ensemble, locked 2026-04-24).
pub pathway_memory: pathway_memory::PathwayMemory,
/// Serializes embed calls from seed_playbook_memory to avoid
/// concurrent socket collisions with the Python sidecar.
pub embed_semaphore: Arc<Semaphore>,
@@ -83,10 +77,6 @@ pub fn router(state: VectorState) -> Router {
.route("/indexes/{name}/bucket", axum::routing::patch(migrate_index_bucket))
.route("/jobs", get(list_jobs))
.route("/jobs/{id}", get(get_job))
// PRD Phase 41 alias — docs/CONTROL_PLANE_PRD.md specifies
// GET /vectors/profile/jobs/{id} for polling profile activations.
// Same handler as /jobs/{id}; the alias just matches the PRD URL.
.route("/profile/jobs/{id}", get(get_job))
.route("/search", post(search_index))
.route("/rag", post(rag_query))
.route("/hybrid", post(hybrid_search))
@@ -146,22 +136,6 @@ pub fn router(state: VectorState) -> Router {
// Phase 45 slice 3 — doc drift detection + human re-admission.
.route("/playbook_memory/doc_drift/check/{id}", post(check_doc_drift))
.route("/playbook_memory/doc_drift/resolve/{id}", post(resolve_doc_drift))
// Pathway memory — consensus-designed sidecar (2026-04-24).
// scrum_master_pipeline POSTs /pathway/insert at the end of each
// review, calls /pathway/query before running the ladder for a
// potential hot-swap, and posts /pathway/record_replay after a
// hot-swap succeeds or fails.
.route("/pathway/insert", post(pathway_insert))
.route("/pathway/query", post(pathway_query))
.route("/pathway/record_replay", post(pathway_record_replay))
.route("/pathway/stats", get(pathway_stats))
// ADR-021 Phase C: pre-review bug-fingerprint retrieval.
.route("/pathway/bug_fingerprints", post(pathway_bug_fingerprints))
// Mem0 ops (J 2026-04-25): upsert/retire/revise/history.
.route("/pathway/upsert", post(pathway_upsert))
.route("/pathway/retire", post(pathway_retire))
.route("/pathway/revise", post(pathway_revise))
.route("/pathway/history/{trace_uid}", get(pathway_history))
.with_state(state)
}
@@ -222,7 +196,7 @@ async fn create_index(
let bucket = req.bucket.clone().unwrap_or_else(|| "primary".to_string());
// Create job and return immediately
let job_id = state.job_tracker.create_embed(&index_name, n_chunks).await;
let job_id = state.job_tracker.create(&index_name, n_chunks).await;
tracing::info!("job {job_id}: indexing '{}' — {} docs → {} chunks (background)", index_name, n_docs, n_chunks);
// Spawn supervised dual-pipeline embedding
@@ -262,13 +236,11 @@ async fn create_index(
chunks_per_sec: rate,
bucket: bucket.clone(),
vector_backend: shared::types::VectorBackend::Parquet,
id_prefix: None,
last_used: None,
build_signature: None,
id_prefix: None,
};
let _ = registry.register(meta).await;
tracker.complete(&jid, Some(json!({ "storage_key": key }))).await;
tracker.complete(&jid, key).await;
tracing::info!("job {jid}: completed — {n_chunks} chunks in {elapsed:.0}s ({rate:.0}/sec)");
}
Err(e) => {
@@ -481,6 +453,51 @@ async fn copy_key(
storaged::ops::put(dst, key, data).await
}
// --- unused legacy function below, kept for reference ---
#[allow(dead_code)]
/// Legacy single-pipeline embedding (replaced by supervisor).
async fn _run_embedding_job_legacy(
job_id: &str,
index_name: &str,
chunks: &[chunker::TextChunk],
ai_client: &AiClient,
store: &Arc<dyn ObjectStore>,
tracker: &jobs::JobTracker,
) -> Result<String, String> {
let batch_size = 32;
let mut all_vectors: Vec<Vec<f64>> = Vec::new();
let start = std::time::Instant::now();
for (i, batch) in chunks.chunks(batch_size).enumerate() {
let texts: Vec<String> = batch.iter().map(|c| c.text.clone()).collect();
let embed_resp = ai_client.embed(EmbedRequest {
texts,
model: None,
}).await.map_err(|e| format!("embed batch {} error: {e}", i))?;
all_vectors.extend(embed_resp.embeddings);
// Update progress
let elapsed = start.elapsed().as_secs_f32();
let rate = if elapsed > 0.0 { all_vectors.len() as f32 / elapsed } else { 0.0 };
tracker.update_progress(job_id, all_vectors.len(), rate).await;
// Log every 100 batches
if (i + 1) % 100 == 0 {
let pct = (all_vectors.len() as f32 / chunks.len() as f32) * 100.0;
let eta = if rate > 0.0 { (chunks.len() - all_vectors.len()) as f32 / rate } else { 0.0 };
tracing::info!("job {job_id}: {}/{} chunks ({pct:.0}%), {rate:.0}/sec, ETA {eta:.0}s",
all_vectors.len(), chunks.len());
}
}
// Store
let key = store::store_embeddings(store, index_name, chunks, &all_vectors).await?;
Ok(key)
}
// --- Job Status ---
async fn list_jobs(State(state): State<VectorState>) -> impl IntoResponse {
@@ -1354,221 +1371,239 @@ async fn activate_profile(
State(state): State<VectorState>,
Path(profile_id): Path<String>,
) -> impl IntoResponse {
tracing::info!("[activate_profile] START profile_id={}", profile_id);
let t0 = std::time::Instant::now();
let profile = match state.catalog.get_profile(&profile_id).await {
Some(p) => p,
None => return Err((StatusCode::NOT_FOUND, format!("profile not found: {profile_id}"))),
};
let job_id = state.job_tracker.create_profile_activation(&profile_id).await;
let job_id_for_response = job_id.clone();
let tracker = state.job_tracker.clone();
let _catalog = state.catalog.clone();
let index_registry = state.index_registry.clone();
let bucket_registry = state.bucket_registry.clone();
let lance = state.lance.clone();
let embedding_cache = state.embedding_cache.clone();
let hnsw_store = state.hnsw_store.clone();
let promotion_registry = state.promotion_registry.clone();
let ai_client = state.ai_client.clone();
let active_profile = state.active_profile.clone();
let profile_name = profile.ollama_name.clone();
let profile_id_clone = profile.id.clone();
let profile_bucket = profile.bucket.clone();
let profile_bound = profile.bound_datasets.clone();
let profile_hnsw = profile.hnsw_config.clone();
let profile_backend = profile.vector_backend.clone();
let _profile_full = profile.clone();
let mut warmed = Vec::new();
let mut failures = Vec::new();
let mut total_vectors = 0usize;
tokio::spawn(async move {
let t0 = std::time::Instant::now();
let mut warmed = Vec::new();
let mut failures = Vec::new();
let mut total_vectors = 0usize;
let job_id = job_id;
// Phase 17 / C: VRAM-aware swap. If another profile currently holds
// the GPU and uses a DIFFERENT Ollama model than the one being
// activated, unload it first (keep_alive=0). Same-model activations
// skip the unload — no point churning a model that's already loaded.
let previous_slot = {
let guard = state.active_profile.read().await;
guard.clone()
};
if let Some(prev) = &previous_slot {
if prev.ollama_name != profile.ollama_name {
match state.ai_client.unload_model(&prev.ollama_name).await {
Ok(_) => tracing::info!(
"profile swap: unloaded '{}' ({} -> {})",
prev.ollama_name, prev.profile_id, profile.id,
),
Err(e) => failures.push(format!(
"unload previous model '{}': {e}", prev.ollama_name,
)),
}
}
}
let previous_slot = {
let guard = active_profile.read().await;
guard.clone()
};
if let Some(prev) = &previous_slot {
if prev.ollama_name != profile_name {
match ai_client.unload_model(&prev.ollama_name).await {
Ok(_) => tracing::info!(
"profile swap: unloaded '{}' ({} -> {})",
prev.ollama_name, prev.profile_id, profile_id_clone,
),
Err(e) => failures.push(format!("unload previous model '{}': {e}", prev.ollama_name)),
// Federation layer 2: if this profile declares its own bucket and
// that bucket isn't registered yet, auto-provision it under the
// configured profile_root. This is the moment a "dormant" profile
// becomes live — its bucket exists and is readable/writable.
if let Some(bucket_name) = profile.bucket.clone() {
if !state.bucket_registry.contains(&bucket_name) {
let root = format!(
"{}/{}",
state.bucket_registry.profile_root().trim_end_matches('/'),
bucket_name.replace(':', "_"),
);
let bc = shared::config::BucketConfig {
name: bucket_name.clone(),
backend: "local".to_string(),
root: Some(root.clone()),
bucket: None,
region: None,
endpoint: None,
secret_ref: None,
};
match state.bucket_registry.add_bucket(bc).await {
Ok(info) => {
tracing::info!(
"profile '{}' activated bucket '{}' (root={}, reachable={})",
profile.id, bucket_name, root, info.reachable,
);
}
Err(e) => {
failures.push(format!(
"auto-provision bucket '{}': {}", bucket_name, e,
));
}
}
}
}
if let Some(bucket_name) = profile_bucket.clone() {
if !bucket_registry.contains(&bucket_name) {
let root = format!(
"{}/{}",
bucket_registry.profile_root().trim_end_matches('/'),
bucket_name.replace(':', "_"),
);
let bc = shared::config::BucketConfig {
name: bucket_name.clone(),
backend: "local".to_string(),
root: Some(root.clone()),
bucket: None,
region: None,
endpoint: None,
secret_ref: None,
};
match bucket_registry.add_bucket(bc).await {
Ok(info) => {
tracing::info!(
"profile '{}' activated bucket '{}' (root={}, reachable={})",
profile_id_clone, bucket_name, root, info.reachable,
);
let all_indexes = state.index_registry.list(None, None).await;
let use_lance = profile.vector_backend == shared::types::VectorBackend::Lance;
for binding in &profile.bound_datasets {
let matched: Vec<_> = all_indexes
.iter()
.filter(|m| &m.source == binding)
.collect();
if matched.is_empty() {
failures.push(format!(
"no vector index found for binding '{}'", binding,
));
continue;
}
for meta in matched {
if use_lance {
// --- Lance activation path ---
// Ensure a Lance dataset exists for this index. If it
// doesn't, auto-migrate from the Parquet blob. Then
// ensure an IVF_PQ index is built.
let bucket = meta.bucket.clone();
let lance_store = match state.lance.store_for_new(&meta.index_name, &bucket).await {
Ok(s) => s,
Err(e) => {
failures.push(format!("{}: lance store init: {e}", meta.index_name));
continue;
}
Err(e) => failures.push(format!("auto-provision bucket '{}': {}", bucket_name, e)),
}
}
}
let all_indexes = index_registry.list(None, None).await;
let use_lance = profile_backend == shared::types::VectorBackend::Lance;
for binding in &profile_bound {
let matched: Vec<_> = all_indexes
.iter()
.filter(|m| &m.source == binding)
.collect();
if matched.is_empty() {
failures.push(format!("no vector index found for binding '{}'", binding));
continue;
}
for meta in matched {
if use_lance {
let bucket = meta.bucket.clone();
let lance_store = match lance.store_for_new(&meta.index_name, &bucket).await {
};
let count = lance_store.count().await.unwrap_or(0);
if count == 0 {
// Auto-migrate from existing Parquet.
let pq_store = match state.bucket_registry.get(&bucket) {
Ok(s) => s,
Err(e) => { failures.push(format!("{}: lance store init: {e}", meta.index_name)); continue; }
Err(e) => { failures.push(format!("{}: bucket: {e}", meta.index_name)); continue; }
};
let count = lance_store.count().await.unwrap_or(0);
if count == 0 {
let pq_store = match bucket_registry.get(&bucket) {
Ok(s) => s,
Err(e) => { failures.push(format!("{}: bucket: {e}", meta.index_name)); continue; }
};
match storaged::ops::get(&pq_store, &meta.storage_key).await {
Ok(bytes) => {
let build_t = std::time::Instant::now();
match lance_store.migrate_from_parquet_bytes(&bytes).await {
Ok(ms) => {
total_vectors += ms.rows_written;
tracing::info!("lance auto-migrate '{}': {} rows in {:.2}s", meta.index_name, ms.rows_written, ms.duration_secs);
warmed.push(WarmedIndex {
index_name: meta.index_name.clone(),
source: meta.source.clone(),
vectors: ms.rows_written,
hnsw_build_secs: build_t.elapsed().as_secs_f32(),
});
}
Err(e) => failures.push(format!("{}: lance migrate: {e}", meta.index_name)),
match storaged::ops::get(&pq_store, &meta.storage_key).await {
Ok(bytes) => {
let build_t = std::time::Instant::now();
match lance_store.migrate_from_parquet_bytes(&bytes).await {
Ok(ms) => {
total_vectors += ms.rows_written;
tracing::info!(
"lance auto-migrate '{}': {} rows in {:.2}s",
meta.index_name, ms.rows_written, ms.duration_secs,
);
warmed.push(WarmedIndex {
index_name: meta.index_name.clone(),
source: meta.source.clone(),
vectors: ms.rows_written,
hnsw_build_secs: build_t.elapsed().as_secs_f32(),
});
}
Err(e) => failures.push(format!("{}: lance migrate: {e}", meta.index_name)),
}
Err(e) => failures.push(format!("{}: read parquet: {e}", meta.index_name)),
}
} else {
total_vectors += count;
Err(e) => failures.push(format!("{}: read parquet: {e}", meta.index_name)),
}
} else {
total_vectors += count;
warmed.push(WarmedIndex {
index_name: meta.index_name.clone(),
source: meta.source.clone(),
vectors: count,
hnsw_build_secs: 0.0,
});
}
// Ensure IVF_PQ vector index exists.
if !lance_store.has_vector_index().await.unwrap_or(false) {
match lance_store.build_index(316, 8, 48).await {
Ok(ix) => tracing::info!(
"lance auto-index '{}': IVF_PQ built in {:.1}s",
meta.index_name, ix.build_time_secs,
),
Err(e) => failures.push(format!("{}: lance IVF_PQ build: {e}", meta.index_name)),
}
}
// Ensure scalar btree on doc_id for O(log N) random fetch.
if !lance_store.has_scalar_index("doc_id").await.unwrap_or(false) {
match lance_store.build_scalar_index("doc_id").await {
Ok(ix) => tracing::info!(
"lance auto-index '{}': doc_id btree built in {:.2}s",
meta.index_name, ix.build_time_secs,
),
Err(e) => failures.push(format!("{}: lance doc_id btree: {e}", meta.index_name)),
}
}
} else {
// --- Parquet + HNSW activation path (existing) ---
let embeddings = match state.embedding_cache.get_or_load(&meta.index_name).await {
Ok(arc) => arc,
Err(e) => {
failures.push(format!("{}: load failed: {}", meta.index_name, e));
continue;
}
};
total_vectors += embeddings.len();
let profile_default = trial::HnswConfig {
ef_construction: profile.hnsw_config.ef_construction,
ef_search: profile.hnsw_config.ef_search,
seed: profile.hnsw_config.seed,
};
let cfg = state
.promotion_registry
.config_or(&meta.index_name, profile_default)
.await;
let build_t = std::time::Instant::now();
match state
.hnsw_store
.build_index_with_config(&meta.index_name, (*embeddings).clone(), &cfg)
.await
{
Ok(_) => {
warmed.push(WarmedIndex {
index_name: meta.index_name.clone(),
source: meta.source.clone(),
vectors: count,
hnsw_build_secs: 0.0,
vectors: embeddings.len(),
hnsw_build_secs: build_t.elapsed().as_secs_f32(),
});
}
if !lance_store.has_vector_index().await.unwrap_or(false) {
match lance_store.build_index(316, 8, 48).await {
Ok(ix) => tracing::info!("lance auto-index '{}': IVF_PQ built in {:.1}s", meta.index_name, ix.build_time_secs),
Err(e) => failures.push(format!("{}: lance IVF_PQ build: {e}", meta.index_name)),
}
}
if !lance_store.has_scalar_index("doc_id").await.unwrap_or(false) {
match lance_store.build_scalar_index("doc_id").await {
Ok(ix) => tracing::info!("lance auto-index '{}': doc_id btree built in {:.2}s", meta.index_name, ix.build_time_secs),
Err(e) => failures.push(format!("{}: lance doc_id btree: {e}", meta.index_name)),
}
}
} else {
let embeddings = match embedding_cache.get_or_load(&meta.index_name).await {
Ok(arc) => arc,
Err(e) => { failures.push(format!("{}: load failed: {}", meta.index_name, e)); continue; }
};
total_vectors += embeddings.len();
let profile_default = trial::HnswConfig {
ef_construction: profile_hnsw.ef_construction,
ef_search: profile_hnsw.ef_search,
seed: profile_hnsw.seed,
};
let cfg = promotion_registry
.config_or(&meta.index_name, profile_default)
.await;
let build_t = std::time::Instant::now();
match hnsw_store
.build_index_with_config(&meta.index_name, (*embeddings).clone(), &cfg)
.await
{
Ok(_) => {
warmed.push(WarmedIndex {
index_name: meta.index_name.clone(),
source: meta.source.clone(),
vectors: embeddings.len(),
hnsw_build_secs: build_t.elapsed().as_secs_f32(),
});
}
Err(e) => failures.push(format!("{}: HNSW build failed: {}", meta.index_name, e)),
Err(e) => {
failures.push(format!("{}: HNSW build failed: {}", meta.index_name, e));
}
}
}
}
}
let mut model_preloaded = false;
match ai_client.preload_model(&profile_name).await {
Ok(_) => {
model_preloaded = true;
tracing::info!("profile '{}' preloaded ollama model '{}'", profile_id_clone, profile_name);
}
Err(e) => failures.push(format!("preload ollama model '{}': {e}", profile_name)),
// Preload the new profile's Ollama model proactively. Same-model
// re-activations are cheap (Ollama no-ops if already loaded).
let mut model_preloaded = false;
match state.ai_client.preload_model(&profile.ollama_name).await {
Ok(_) => {
model_preloaded = true;
tracing::info!("profile '{}' preloaded ollama model '{}'",
profile.id, profile.ollama_name);
}
Err(e) => failures.push(format!(
"preload ollama model '{}': {e}", profile.ollama_name,
)),
}
{
let mut guard = active_profile.write().await;
*guard = Some(ActiveProfileSlot {
profile_id: profile_id_clone.clone(),
ollama_name: profile_name.clone(),
activated_at: chrono::Utc::now(),
});
}
// Take the GPU slot.
{
let mut guard = state.active_profile.write().await;
*guard = Some(ActiveProfileSlot {
profile_id: profile.id.clone(),
ollama_name: profile.ollama_name.clone(),
activated_at: chrono::Utc::now(),
});
}
let result = serde_json::to_value(ActivateReport {
profile_id: profile_id_clone,
ollama_name: profile_name,
indexes_warmed: warmed,
failures,
total_vectors,
duration_secs: t0.elapsed().as_secs_f32(),
model_preloaded,
previous_profile: previous_slot.map(|s| s.profile_id),
}).ok();
let report = ActivateReport {
profile_id: profile.id,
ollama_name: profile.ollama_name,
indexes_warmed: warmed,
failures,
total_vectors,
duration_secs: t0.elapsed().as_secs_f32(),
model_preloaded,
previous_profile: previous_slot.map(|s| s.profile_id),
};
tracker.complete(&job_id, result).await;
});
// PRD Phase 41 gate: "Activate a profile → returns 202 in <100ms
// → job completes in background". 202 ACCEPTED signals async-work
// started; clients poll /vectors/jobs/{job_id} for progress.
Ok((StatusCode::ACCEPTED, Json(json!({
"job_id": job_id_for_response,
"message": format!("profile activation started — poll /vectors/jobs/{} for progress", job_id_for_response),
}))))
Ok(Json(report))
}
/// Unload this profile's model and clear the active slot. No-op if the
@@ -2818,149 +2853,6 @@ async fn lance_build_scalar_index(
}
}
// ─── Pathway memory handlers ──────────────────────────────────────────
//
// Thin wrappers around pathway_memory::PathwayMemory. HTTP surface is
// deliberately small — four endpoints cover the full lifecycle:
// insert at end-of-review, query before running the ladder,
// record_replay after a hot-swap, and stats for the VCP UI.
#[derive(Deserialize)]
struct PathwayQueryRequest {
task_class: String,
file_path: String,
signal_class: Option<String>,
query_vec: Vec<f32>,
}
async fn pathway_insert(
State(state): State<VectorState>,
Json(trace): Json<pathway_memory::PathwayTrace>,
) -> impl IntoResponse {
match state.pathway_memory.insert(trace).await {
Ok(()) => Ok(Json(json!({"ok": true}))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}
async fn pathway_query(
State(state): State<VectorState>,
Json(req): Json<PathwayQueryRequest>,
) -> impl IntoResponse {
let cand = state
.pathway_memory
.query_hot_swap(
&req.task_class,
&req.file_path,
req.signal_class.as_deref(),
&req.query_vec,
)
.await;
// 200 with null candidate means "no hot-swap"; this is a normal
// path, not an error — callers should proceed with the full ladder.
Json(json!({ "candidate": cand }))
}
#[derive(Deserialize)]
struct PathwayReplayRequest {
pathway_id: String,
succeeded: bool,
}
async fn pathway_record_replay(
State(state): State<VectorState>,
Json(req): Json<PathwayReplayRequest>,
) -> impl IntoResponse {
match state
.pathway_memory
.record_replay_outcome(&req.pathway_id, req.succeeded)
.await
{
Ok(()) => Ok(Json(json!({"ok": true}))),
Err(e) => Err((StatusCode::NOT_FOUND, e)),
}
}
async fn pathway_stats(State(state): State<VectorState>) -> impl IntoResponse {
Json(state.pathway_memory.stats().await)
}
#[derive(Deserialize)]
struct PathwayBugFingerprintsRequest {
task_class: String,
file_path: String,
signal_class: Option<String>,
limit: Option<usize>,
}
async fn pathway_bug_fingerprints(
State(state): State<VectorState>,
Json(req): Json<PathwayBugFingerprintsRequest>,
) -> impl IntoResponse {
let fps = state
.pathway_memory
.bug_fingerprints_for(
&req.task_class,
&req.file_path,
req.signal_class.as_deref(),
req.limit.unwrap_or(5),
)
.await;
Json(json!({ "fingerprints": fps }))
}
// ─── Mem0 ops endpoints (J 2026-04-25) ───
async fn pathway_upsert(
State(state): State<VectorState>,
Json(trace): Json<pathway_memory::PathwayTrace>,
) -> impl IntoResponse {
match state.pathway_memory.upsert(trace).await {
Ok(outcome) => Ok(Json(json!({"ok": true, "outcome": outcome}))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}
#[derive(Deserialize)]
struct PathwayRetireRequest {
trace_uid: String,
reason: String,
}
async fn pathway_retire(
State(state): State<VectorState>,
Json(req): Json<PathwayRetireRequest>,
) -> impl IntoResponse {
match state.pathway_memory.retire(&req.trace_uid, &req.reason).await {
Ok(touched) => Ok(Json(json!({"ok": true, "retired": touched}))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}
#[derive(Deserialize)]
struct PathwayReviseRequest {
parent_trace_uid: String,
new_trace: pathway_memory::PathwayTrace,
}
async fn pathway_revise(
State(state): State<VectorState>,
Json(req): Json<PathwayReviseRequest>,
) -> impl IntoResponse {
match state.pathway_memory.revise(&req.parent_trace_uid, req.new_trace).await {
Ok(outcome) => Ok(Json(json!({"ok": true, "outcome": outcome}))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}
async fn pathway_history(
State(state): State<VectorState>,
axum::extract::Path(trace_uid): axum::extract::Path<String>,
) -> impl IntoResponse {
let chain = state.pathway_memory.history(&trace_uid).await;
Json(json!({"trace_uid": trace_uid, "chain_len": chain.len(), "chain": chain}))
}
#[cfg(test)]
mod extractor_tests {
use super::*;
+2 -1
View File
@@ -2,8 +2,9 @@
/// Each embedding index is stored as: source, doc_id, chunk_idx, chunk_text, vector (binary blob).
/// Vectors are stored as raw f32 bytes for compact storage and fast loading.
use arrow::array::{ArrayRef, BinaryArray, Int32Array, RecordBatch, StringArray};
use arrow::array::{ArrayRef, BinaryArray, Float32Array, Int32Array, RecordBatch, StringArray};
use arrow::datatypes::{DataType, Field, Schema};
use bytes::Bytes;
use object_store::ObjectStore;
use std::sync::Arc;
+1 -1
View File
@@ -170,7 +170,7 @@ pub async fn run_supervised(
// Update job tracker
let elapsed = start_time.elapsed().as_secs_f32();
let rate = if elapsed > 0.0 { done as f32 / elapsed } else { 0.0 };
tracker.update_embed_progress(&jid, done, rate).await;
tracker.update_progress(&jid, done, rate).await;
// Update checkpoint
let mut ckpt = ckpt.write().await;
+1
View File
@@ -10,6 +10,7 @@
/// JSONL on every event. See `append_log.rs` for the full rationale.
use chrono::{DateTime, Utc};
use object_store::ObjectStore;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Arc;
-5
View File
@@ -99,8 +99,3 @@
**Date:** 2026-04-19
**Decision:** `catalogd::Registry::register(name, fingerprint, objects)` is idempotent on `name`. If no manifest for `name` exists, create one. If one exists with the same `schema_fingerprint`, reuse its `DatasetId`, replace `objects`, bump `updated_at`, and write through. If one exists with a different `schema_fingerprint`, reject with `409 Conflict` (HTTP) / `FAILED_PRECONDITION` (gRPC). A one-shot operator endpoint `POST /catalog/dedupe` collapses any pre-existing duplicates (preferring the manifest with a non-null `row_count`, then the most recently updated).
**Rationale:** Registry was keyed by surrogate `DatasetId` with no uniqueness constraint on `name`, so every caller that re-registered (re-ingest, external cron, gRPC retry) silently created a parallel manifest pointing at the same parquet — accumulating 308× `successful_playbooks` in live state before detection. The fingerprint gate turns re-ingest into an explicit no-op (matching PRD invariant #5 "ingestd is idempotent — re-ingesting the same file is a no-op") while forcing schema drift to be visible instead of silently clobbering. 409 status separates policy rejections from server errors, which matters for the Phase 12 tool-consumer ecosystem. Concurrency: the write lock is held across the storage write to close the check→insert TOCTOU window; serializing registers is acceptable because registers-per-second is low on the ingest path. Audit: idempotent-register events are visible as bumps to the stored manifest's `updated_at` field and in `catalogd` tracing output (tracing is non-durable, operator view only); `DedupeReport` is the return-value audit for cleanup runs. No event-journal entries are emitted — ADR-012 scopes the journal to row-level mutations, not catalog-manifest operations.
## ADR-021: Semantic-correctness layer on pathway_memory — matrix-indexed bug grammar
**Date:** 2026-04-24
**Decision:** Extend `pathway_memory::PathwayTrace` (ADR added 2026-04-24 in same commit as this one) with a semantic-correctness layer so the matrix index compounds recognition of unit/type/shape bugs across iterations. Three new fields: `semantic_flags: Vec<SemanticFlag>` (enum: `UnitMismatch`, `TypeConfusion`, `NullableConfusion`, `OffByOne`, `StaleReference`, `PseudoImpl`, `DeadCode`, `WarningNoise`, `BoundaryViolation`), `type_hints_used: Vec<TypeHint>` (schema/type context the reviewer was given — catalogd column types for SQL-touching code, Arrow `RecordBatch.schema()` accessors for Rust, Rust struct field types for everything else), and `bug_fingerprints: Vec<BugFingerprint>` (structural pattern hash, e.g. `{lhs_unit: "rows", rhs_unit: "files", op: "-"}` → stable SHA for similarity retrieval). Scrum pipeline pre-review: query matrix index for bug fingerprints flagged on this file's narrow fingerprint (same `task_class + file_prefix + signal_class` as hot-swap) and prepend them to the reviewer prompt as "watch for these patterns historically found here." Reviewer prompt explicitly tags each finding with a `semantic_flag`. `truth::evaluate()` gets a review-time task_class (`code_review.unit_check`) that consumes parsed-fact rules like `FieldContainsAny { field: "code_expression", needles: ["row_count - file_count", "bytes_read - row_count"] }` — the same primitive we use for SQL guard in P42-002.
**Rationale:** The 2026-04-24 `queryd/src/delta.rs` `base_rows = pre_filter_rows - delta_count` bug (86901f8) was found by a human reading the code and noticing units didn't match. The hardened mechanical applier *cannot* catch this — its gates are syntactic (warning count, patch size, rationale-token alignment) not semantic. At 100 bugs this deep, no human catches them all; the signal→commit loop is capped by what humans can notice per iteration. We already ship the primitives: `catalogd` knows column types per dataset, Arrow `RecordBatch.schema()` is on every hot-path call, `truth::evaluate()` runs arbitrary field conditions at runtime, `shared/arrow_helpers` has typed row/byte/file accessors. All of this is used at RUNTIME; none is fed into the REVIEW pipeline. Semantic flags + bug fingerprints turn the matrix index from "what review happened" (current) into "what category of bug appeared where" (compounding) — so iter-20 scrum on `crates/queryd/src/` preempts review prompts with "this crate had a row/file unit mismatch in iter 7 (delta.rs:189); check every arithmetic on `*_count` variables." Non-goals: we are NOT building a full type-inference engine (reuse Rust's `rustdoc`-level type info for structs, Arrow's schema for RecordBatch, catalogd's column types for SQL — everything beyond is Phase 3). Non-goals: this is not a linter — clippy/rustc already catch syntactic issues; this catches SEMANTIC ones (same type, wrong units/role). Bootstrap path: start with the 9 `SemanticFlag` variants above; add new variants only when a bug is found that doesn't fit an existing one. Gate alignment with hot-swap: a pathway that repeatedly produces bugs of the same `SemanticFlag` variant on the same narrow fingerprint is more valuable as a "watch this file for X" signal than as a hot-swap candidate — retirement logic needs to consider both replay success_rate AND whether the pathway is serving as a bug-pattern beacon.
-25
View File
@@ -324,31 +324,6 @@
- Mem0-style upsert: `/seed` with `append=true` (default) routes through `upsert_entry`, which decides ADD / UPDATE / NOOP on (operation, day, city, state). Same-day re-seed merges names (union, stable order) instead of duplicating the row. Identical re-seed is a no-op. Different-day same-operation is a fresh ADD. Playbook_id stays stable on UPDATE so prior citations remain valid.
- Letta-style hot cache: `PlaybookMemory` now holds a `geo_index: HashMap<(city_lower, state_upper), Vec<entry_idx>>` rebuilt on every mutation. Geo-filtered boost queries skip the full scan and hit the O(1) key lookup. At 1.9K entries the full scan was sub-ms; the index scales the same path to 100K+ without code changes.
- `UpsertOutcome` enum reported back to callers — `{mode: added|updated|noop, playbook_id, merged_names?}` + `entries_after`.
- [x] **Phase 37: Hot-swap async** (2026-04-22)
- Extended `JobTracker` with `JobType::ProfileActivation` + `Embed` enum variants
- Made `activate_profile` return immediately with `job_id`, work runs in background via `tokio::spawn`
- Background jobs tracked via `POST /vectors/jobs/{id}` + `GET /vectors/jobs`
- [x] **Phase 38: Universal API Skeleton** (2026-04-23)
- `/v1/chat` — OpenAI-compatible POST, forwards to local Ollama or Ollama Cloud
- `/v1/usage` — returns `{requests, prompt_tokens, completion_tokens, total_tokens}`
- `/v1/sessions` — returns `{data: [], note: "Phase 38: stateless"}`
- Langfuse trace integration (fire-and-forget, Phase 40 early)
- 12 unit tests green, curl gates pass
- [x] **Phase 39: Provider Adapter Refactor** (2026-04-23)
- `ProviderAdapter` trait with `chat()` + `embed()` + `unload()` + `health()`
- `OllamaAdapter` — wraps existing AiClient
- `OpenRouterAdapter` — HTTP client to openrouter.ai
- `provider_key()` routing by model prefix (openrouter/* → OpenRouter)
- [x] **Phase 40: Routing & Policy Engine** (2026-04-23)
- `RoutingEngine` with `RouteDecision` in aibridge::routing
- `config/routing.toml` — rules by model_pattern, fallback chain, cost gating
- Per-provider usage tracking: Usage.by_provider
- 12 gateway tests green, curl gates pass
- [x] **Phase 41: Profile System Expansion** (2026-04-23)
- `ProfileType` enum: Execution, Retrieval, Memory, Observer
- Per-type endpoints: `/profiles/retrieval`, `/profiles/memory`, `/profiles/observer`
- `profile_type` field on ModelProfile
- Guard fix: automated scrumaudit.py finds real issues
- [ ] Fine-tuned domain models (Phase 25+)
- [ ] Multi-node query distribution (only if ceilings bite)
-63
View File
@@ -1,63 +0,0 @@
# Scrum Fix Wave — Phase-Sweep 2026-04-23
**Purpose:** Direct the scrum-master pipeline at concrete findings from the Phase 0→42 audit sweep, not at the high-level vision alone. Findings live in `data/_kb/phase_sweep_findings.jsonl` (19 items).
## What the auditor expects you to produce per file
For each file the scrum sees: concrete code-level suggestions that close the listed findings. Not rewrites for style. Not vision drift. Land the invariant or admit the checkbox was premature.
## Meta-pattern to fix (read this first)
The sweep surfaced **one root cause repeated across 5 phases**: primitives exist, cross-cutting enforcement doesn't. Auth, journal, access control, truth rules — the machinery is built, nothing calls it from the actual request path.
One PR-cluster retires the pattern:
1. **Identity.** Auth middleware wires X-API-Key → extension `AgentIdentity { name, role, api_key_hash }`. (P5-001)
2. **Request pipeline.** `/query/sql` and `/tools/*/call` read `AgentIdentity`, pass into queryd + tools handlers.
3. **Access enforcement.** Handlers call `access.can_access()` / `masked_columns()` before returning data; log via `access.log_query()`. (P13-001)
4. **Mutation journaling.** Every ingest / delta-write / tombstone-add / catalog-register calls the corresponding `journal.record_*`. (P9-001)
5. **Truth enforcement.** `TruthStore::check()` rewritten to `evaluate(task_class, ctx) -> Vec<RuleOutcome>`, actually walking `RuleCondition` against context. (P42-001, P42-002)
After this cluster lands, Phases 5, 9, 13, 42 become "truly shipped" rather than "machinery shipped."
## Findings by severity
### 🔴 High
- **P9-001** `journald/src/journal.rs`, `crates/gateway/src/main.rs`, all mutation sites. Journal has zero internal callers. Every `ingestd::service::upload_file` success, every `queryd::delta::write_delta`, every `catalogd::tombstones::add`, every `catalogd::registry::register` should emit a journal event. Plus fix: `event_counter` resets on process restart — seed from max existing `event_id` on rebuild or switch to UUID v7.
- **P13-001** `crates/gateway/src/main.rs` + `crates/queryd/src/service.rs` + `crates/gateway/src/tools/service.rs`. `AccessControl.can_access` / `masked_columns` / `log_query` have zero callers. Query path ignores role. Phase 17's `profile_scoped_search` (service.rs:1641) is the template — copy that shape.
- **P42-001** `crates/truth/src/lib.rs:56`. `TruthStore::check(task_class)` ignores `RuleCondition` entirely. Signature needs `evaluate(task_class, ctx: &serde_json::Value) -> Vec<RuleOutcome>` that actually walks conditions. Update all 14 tests to exercise fail/pass semantics, not just storage.
### 🟡 Medium
- **P5-001** `crates/gateway/src/auth.rs` + `crates/gateway/src/main.rs:222-233`. `api_key_auth` is `#[allow(dead_code)]`. Wire with `axum::middleware::from_fn_with_state(api_key, auth::api_key_auth)` on protected routes. `/health` stays public.
- **P10-001** Legacy datasets (including `candidates`, 2.47M rows) have no PII flags. Add `POST /catalog/resync-metadata` mirroring `/catalog/resync-missing`.
- **P14-001** `crates/ingestd/src/schema_evolution.rs`. Module has 5 passing tests and zero callers. Add `POST /catalog/datasets/by-name/{name}/schema-diff`. When ADR-020 `register()` returns 409, include a `migration_rules[]` body.
- **P20-001** `config/models.json` is spec-only — never loaded by Rust. Load into `shared::model_matrix::ModelMatrix` at startup; delegate `aibridge::context::context_window_for` to matrix.
- **P21-001** `generate_continuable` has one prod caller (`rag.rs:171`). Audit every `ai_client.generate()` site. Convert the truncation-prone + thinking-empty-prone sites (auditor paths, reranker, autotune) to `generate_continuable`.
- **P39-001** `ProviderAdapter` trait + adapters ship, zero callers. `/v1/chat` in `v1/mod.rs:152` uses hardcoded `match req.provider`. Replace with adapter dispatch.
- **P40-001** `config/routing.toml` is spec-only. `RoutingEngine::new` has no callers. Add `RoutingEngine::from_toml(path)`; `V1State` carries `routing: Arc<RoutingEngine>`; `/v1/chat` consults it before provider match.
- **P42-002** Truth has no enforcement site. `/v1/chat` or execution_loop should call `truth.evaluate(task_class, ctx)` post-response.
### 🟢 Low
- **P1-001** `crates/storaged/src/federation_service.rs:34-35`. Bucket-qualified routes wire only PUT + GET. Add DELETE + LIST on `/buckets/{bucket}/objects/{*key}`.
- **P4-001** UI deploy stale. Either rebuild (`just ui-build` + restart `lakehouse-ui.service`) or amend `PHASES.md` to note pre-Phase-9 drift.
- **P7-001** `vectord::index_registry`. Orphan index registrations (parquet deleted) still list in `/vectors/indexes`. Add a startup sweep + `POST /vectors/resync-missing`.
- **P12-001** `crates/gateway/src/tools/service.rs`. Audit row has `row_count=null`. Propagate `QueryResponse.row_count` + add `latency_ms`.
- **P20-002** `crates/gateway/src/v1/mod.rs`. No model-prefix auto-routing. Caller must set `provider` explicitly. Tie to P39-001 + P40-001 fix.
- **P21-002** `crates/aibridge/src/context.rs`. `context_window_for` hardcoded HashMap duplicates `config/models.json`. Delegate once P20-001 lands.
- **P38-001** `crates/gateway/src/execution_loop/mod.rs:1523`. Test `executor_prompt_includes_surfaced_candidates` fails on "W-1 Alice Smith" assertion. Either update prompt formatter or update test.
- **P40-002** Cost gating absent. Add `cost_ceiling_usd_per_hour` to `RoutingEngine` rule, pre-request check against `Usage.by_provider`.
## What "done" looks like for each file the scrum touches
- Name the specific finding(s) the file participates in.
- Show code-level diff (minimum: function signature + first 5 lines of body) for the fix.
- Call out any test that needs updating + one new test that would catch the bug on reintroduction.
- Flag if the fix is too big for one PR and should be split (most of the cross-cutting cluster wants a shared identity/middleware PR first, per-service PRs after).
## Out of scope for this wave
- New features beyond what the findings describe.
- UI work (Phase 4 stale is known).
- DevOps / long-horizon domain work (Terraform/Ansible — Phase 43+).
-198
View File
@@ -1,198 +0,0 @@
# Scrum Master PR Loop — Forensic Validation Prompt (iter 2+)
Adopted 2026-04-23 from J. Replaces the default scrum prompt starting iter 2. Iter 1 used the softer "fix-wave" framing; iter 2 onward uses this adversarial one.
---
You are acting as an adversarial **Scrum Master + Systems Auditor**.
Your job is to **prove whether this system actually works**, not to describe it.
You are auditing a system with the following architecture:
- AI Gateway with per-model adapters
- Output normalization + schema validation layer
- Execution pipeline (Terraform / Ansible / shell)
- Task-scoped execution memory (S3 + Apache Arrow/Parquet)
- Relevance orchestration (context filtering, freshness validation, fact extraction)
- Local → Cloud fallback loop for failed tasks
- Iterative repair loop with stored execution evidence
---
## PRIMARY OBJECTIVE
Determine if the system is:
1. Executable (real, not pseudocode)
2. Aligned with PRD contracts
3. Deterministic enough to trust
4. Protected from model output drift
5. Actually closing the loop (fail → repair → reuse)
---
## NON-NEGOTIABLE RULES
- Do NOT summarize
- Do NOT explain architecture unless tied to failure
- Do NOT assume code works — verify
- Every claim MUST reference files, functions, or execution evidence
- If something is unclear → mark as FAIL
---
## AUDIT PASSES (RUN ALL)
### 1. PSEUDOCODE / FAKE IMPLEMENTATION DETECTION
Find any:
- TODO / stub / placeholder
- hardcoded outputs where AI should decide
- mocked execution paths
- fake success returns
Output exact file + line references.
### 2. PRD CONTRACT VALIDATION
Verify implementation exists for:
- Gateway routing logic
- Per-model adapters
- Output normalization (strip, parse, canonicalize)
- Schema validation layer
- Repair loop (retry with modification)
- Raw output storage
- Execution memory persistence
- Retrieval based on prior failures
- Relevance filtering (freshness / protocol awareness)
- Execution permission gate
For each component:
- status: implemented | partial | missing
- include file references
### 3. NORMALIZATION + VALIDATION PIPELINE
Prove that:
- Raw model output is NEVER executed directly
- JSON extraction is enforced
- Unknown fields are rejected or handled
- Schema validation blocks bad output
- Repair loop triggers on failure
If any path bypasses validation → FAIL
### 4. FAILURE → CLOUD → REPAIR LOOP
Trace the loop:
- Local model fails
- Failure is classified
- Context is packaged
- Cloud model returns corrective instruction
- Local model retries
- Result is validated
- Successful pattern is stored
If any step is missing or non-deterministic → FAIL
### 5. EXECUTION MEMORY (S3 / ARROW)
Verify:
- Raw runs are stored (input, raw output, normalized output)
- Failures are recorded with signatures
- Successful retries are recorded
- Retrieval pulls based on:
- task similarity
- failure signature
- execution success history
If memory is only logs and not reused → FAIL
### 6. RELEVANCE ORCHESTRATION
Verify:
- Context is filtered before model input
- Freshness or version awareness exists
- Fact extraction reduces noise
- Context inclusion is explainable
If system blindly injects context → FAIL
### 7. EXECUTION SAFETY
Verify:
- No shell / terraform / ansible execution without validation gate
- No direct model-to-command execution
- Clear permission boundary exists
If AI can execute commands unchecked → CRITICAL FAIL
### 8. TESTING + EVIDENCE
Find:
- real tests (not mocks)
- execution logs
- validation results
- success/failure traces
If no proof of execution → FAIL
---
## OUTPUT FORMAT (STRICT)
Each finding in any array MUST include a `confidence` field (integer 0–100). The confidence represents your self-assessed probability that the finding is correct and actionable. Low confidence is valuable — do not inflate. A finding with confidence < 50 is still recorded (it signals investigation needed) but downstream consumers will weight it less.
```json
{
"verdict": "pass | fail | needs_patch",
"critical_failures": [
{"id": "CF-1", "file": "path:line", "description": "...", "confidence": 95}
],
"pseudocode_flags": [
{"file": "path:line", "reason": "...", "confidence": 88}
],
"prd_mismatches": [
{"component": "...", "status": "partial|missing", "file_ref": "...", "confidence": 80}
],
"broken_pipelines": [
{"pipeline": "...", "break_point": "...", "confidence": 70}
],
"missing_components": [
{"component": "...", "required_by": "PRD section X", "confidence": 85}
],
"risk_points": [
{"area": "...", "risk": "...", "confidence": 60}
],
"verified_components": [
{"component": "...", "evidence": "file:line or test name", "confidence": 95}
],
"evidence": {
"files_inspected": [],
"execution_paths_traced": [],
"tests_found": [],
"tests_missing": []
},
"required_next_actions": [
{"action": "...", "file_hint": "...", "confidence": 75}
]
}
```
**Calibration guide:**
- 90–100: pattern seen repeatedly in shipped code; mechanical; low regression risk
- 70–89: confident in direction, API shape or naming may vary
- 50–69: plausible fix but may not match conventions, could cascade
- <50: genuinely uncertain — record anyway so downstream knows to investigate
---
## FINAL DIRECTIVE
You are not reviewing code.
You are answering:
> "Can this system be trusted to execute real-world DevOps tasks without hallucinating, bypassing validation, or collapsing under edge cases?"
If the answer is not provably yes, the verdict is FAIL.
-95
View File
@@ -1,95 +0,0 @@
# Scrum Loop Notes — Observations across iterations
Running notes from the 6x scrum loop (started 2026-04-23). One section per iteration. "Fix next loop" items accumulate here so the next scrum run picks them up — do not fix inline during a running iteration.
## Iteration tracker
| Iter | Status | Scrum started | Scrum finished | Fixes applied | Build green | Re-sweep findings |
|---|---|---|---|---|---|---|
| 1 | 🟡 scrum running | 2026-04-23 (brqz3jxgo) | - | - | - | - (baseline = 19) |
| 2 | 🟡 scrum running | 2026-04-23 (bzs6miehr) | - | - | - | pending |
| 3 | ⬜ queued | - | - | - | - | - |
| 4 | ⬜ queued | - | - | - | - | - |
| 5 | ⬜ queued | - | - | - | - | - |
| 6 | ⬜ queued | - | - | - | - | - |
## Iteration 1 — in flight
**Target files:** 21 source files extracted from the 19 Phase 0→42 findings.
**Ladder:** cloud-first per feedback_scrum_cloud_first.md (gpt-oss:120b → qwen3.5:397b → devstral-2:123b → mistral-large-3:675b → gpt-oss:20b → qwen3.5:latest).
**Proposal:** `docs/SCRUM_FIX_WAVE.md` (via LH_SCRUM_PROPOSAL env).
### Fix next loop — observations accumulating
*Add items here as the scrum runs. Keep each item to one line with a pointer to file + reason. Don't fix inline.*
**[ITER 2 OBSERVATIONS]**
- **[FORENSIC vs thin-detector mismatch]** iter 2 first attempt on auth.rs triggered "thin/unstructured" rejection at 2031 chars. Cause: forensic prompt asks for strict JSON verdict output, scrum's thin-answer detector expects markdown with score + table. The detector logic needs a forensic-aware branch OR the forensic prompt should preserve markdown output shape while still applying the 8 audit passes. File: `tests/real-world/scrum_master_pipeline.ts`, function that scores accepted vs thin. Fix next loop: add `isForensicAcceptable(text)` that checks for `"verdict"` field + at least one of `critical_failures`/`pseudocode_flags`/`required_next_actions`.
- **[OBSERVATION metric]** 11 `#[allow(dead_code)]` markers cluster in crates/gateway/{auth,access,tools/registry,execution_loop,v1/truth} + crates/aibridge/providers/openrouter + crates/vectord/service. Each one maps cleanly to an audit finding. The `execution_loop/mod.rs:85` comment even admits it: `// reserved for Phase 42 truth-gate (step 6)`. **Metric:** fewer `#[allow(dead_code)]` markers per iteration = less pseudo-real code. Baseline = 11. Target after iter 6: ≤ 2 (only ones that are genuinely optional helpers).
- **[OBSERVATION gateway-as-router]** scrum_master_pipeline currently fetches `GATEWAY/v1/chat` directly but its LADDER is still a hardcoded const. Should be driven by `config/routing.toml` via RoutingEngine (blocked by P40-001 until iter 1 lands fix). File: `tests/real-world/scrum_master_pipeline.ts:53`.
- **[OBSERVATION file-type]** iter 1 target list is `.rs` only. Iter 2 must include `tests/multi-agent/*.ts` (executor, observer, kb consumer), `auditor/checks/*.ts`, `sidecar/sidecar/*.py`, and `config/*.{json,toml}`. The scrum pipeline handles any text file.
- **[OBSERVATION triangulation]** auth.rs scrum review (first file out) independently identified P5-001 exactly: flagged `#[allow(dead_code)]`, scored alignment 4/10, prescribed an `AgentIdentity { name, role, hashed_key }` type matching SCRUM_FIX_WAVE. Audit + scrum converged without seeing each other's output — strong signal the findings are real, not artifacts of one method.
- **[RULE from J 2026-04-23]** Wiring-gap fixes happen AFTER the scrum completes, not inline. Accumulate observations, apply in one coherent pass. Matches feedback_audit_findings_log.md.
- **[OBSERVATION oversize-file]** `crates/gateway/src/execution_loop/mod.rs` is 80,901 chars → 24 shards (scrum pipeline's tree-split kicks in at 6KB threshold). A single-file-of-this-size for an execution module is itself a smell — it's the Phase 43 scaffold we kept piling into. Split candidates: executor prompts, reviewer prompts, budget accounting, truth-gate hook, fixtures. Not a fix for this iter, but queue for iter 3.
- **[OBSERVATION cost-tracking]** zero escalations across first 8 files — 0.0 dollar cloud spend above the minimum. Per-request cost on gpt-oss:120b via Ollama Cloud is effectively $0 in this environment (self-hosted or flat-rate per the llm_team_config key). If we add per-iter token totals to scrum_loop_metrics.jsonl we can show trajectory even when cost is flat.
**[ITER 3 OBSERVATIONS]**
- **[LARGE-HANDLER thin]** kimi-k2:1t went thin on `crates/gateway/src/tools/service.rs` (~11KB, single large axum handler). deepseek-v3.1:671b rescued on attempt 2 (92.8s, 5408 chars, accepted). Pattern: very large routing files challenge even 1T models. Fix next loop: raise tree-split threshold for handler files OR shard by function boundaries not byte count.
- **[WRITE-ONLY INDICATORS STILL]** 8 KB files write-only after iter 3: `audits.jsonl` (189 rows/1.9MB — biggest waste), `phase_sweep_findings.jsonl` (35), `distilled_facts.jsonl` (17), `human_overrides.jsonl` (8), `classifications.jsonl` (5), `scrum_loop_metrics.jsonl` (2), `distilled_config_hints.jsonl` (2), `distilled_procedures.jsonl` (2). Fix next loop: extend `auditor/checks/kb_query.ts` to surface these on PR review, OR build a single "KB health dashboard" reader.
- **[ISOLATED AUTOTUNE]** `crates/vectord/src/agent.rs` has zero refs to scrum/audit/human_override KB. It tunes HNSW but doesn't know which indexes are attached to files the scrum flagged. Fix next loop: add `TriggerEvent::CodeReviewFlag { index_name, gradient_tier }` that biases trial budget toward indexes of flagged files.
- **[CONFIDENCE WELL-CALIBRATED]** kimi-k2:1t confidences span 75-98 across iter3 files, cluster 85-95. No 100% inflation; min 75 = honest edge-case uncertainty. Good signal — the model is calibrating, not performance-signaling. Do NOT "fix" this by prompt-boosting confidence.
- **[SCRUM→OBSERVER WIRED]** 2026-04-24 fix landed in iter-3 source but applies to iter 4+ (bun loaded code pre-edit). Verify next loop: `curl :3800/stats` should show `by_source.scrum > 0` after iter 4 runs. If zero, observer /event payload schema mismatch.
- **[LOW-CONFIDENCE BLOCK flag WORKING]** `crates/storaged/src/federation_service.rs` scored 3/10 with confidences **[40, 35, 50]** → avg 42, min 35. Permission Gradient correctly classifies this file as `block` tier (min <50) — exactly the signal we want. kimi-k2:1t is honestly saying "I'm not confident about these suggestions." Human must review before any auto-apply. Validates Layer #6 working end-to-end. **DO NOT fix this** — it's the system protecting us.
- **[CONFIDENCE-COLUMN DROPOUT]** `crates/gateway/src/main.rs` accepted review has 0 confidences extracted despite the prompt requiring them. Either kimi-k2:1t omitted the Confidence column in its table format (regex didn't match), or the specific content ran into token budget and confidence got trimmed. Fix next loop: strengthen the prompt instruction to say "if you omit the Confidence column you MUST provide Confidence: NN% inline after every suggestion" AND post-accept sanity-check the row (reject if findings_count=0 across 5+ suggestions).
- **[SCORE-PARSER MISS on some files]** `crates/queryd/src/service.rs` and `crates/queryd/src/delta.rs` reviews return score='?' — regex `(\d)\s*/\s*10` isn't matching. Models may have written score as "Score: 4.5/10" or "4/10 (mid)" which the parser can't see. Fix next loop: regex accepts decimals + surrounding context. Not critical — confidences still landed.
- **[SCORE DISTRIBUTION]** across 12 iter-3 files: 3/10 (7 files), 4/10 (2), 6/10 (1), ?/? (2). vectord/index_registry.rs alone scored 6/10 — meaningfully higher. That's worth a closer look: what does that file do right? If we find the pattern there and replicate to peers, scores should rise. This is learning-loop signal.
- **[FORENSIC JSON OUTPUT IS RICHER THAN MARKDOWN]** kimi-k2:1t emitted strict JSON-schema output (matching SCRUM_FORENSIC_PROMPT.md) for `vectord/src/index_registry.rs`. Structure separates critical_failures / pseudocode_flags / prd_mismatches / broken_pipelines / missing_components / risk_points / **verified_components** / required_next_actions — each with confidence per entry. Markdown reviews never captured `verified_components` (what's confirmed working). Fix next loop: scrum pipeline detects JSON format in accepted output and extracts structured fields into KB row (`verified_components_count`, `critical_failures_count`, `missing_components_count`, `verdict`). Downstream consumers then filter PR files by `verdict: needs_patch` or prioritize by `missing_components_count`. **This is the biggest next-iter KB quality jump available** — goes from "confidence as a scalar" to "confidence per specific claim with evidence field." Verified_components in particular is the PROOF-OF-LIFE signal that tells us what's real — flipside of P9/P13/P42 gaps.
- **[🔴 TREE-SPLIT REVIEW SHARD-LEAKAGE]** `ingestd/src/service.rs` (7 shards, 24.3KB) accepted output is titled "Forensic Audit Report – ... (shard 3)". The review covers only the Postgres-import path (shard 3). The `ingest_file` handler where my P9-001 fix lives (journal.record_ingest call) is in shard 1 or 2 — that reviewer never saw the fix. **`tree_split_fired: true` is supposed to mean the output is the reducer-merged summary of all shards**, but this review retained shard-specific scope. Either (a) the reduce step didn't integrate shard summaries, (b) the accepted attempt was one individual shard response that slipped past the reducer, or (c) the reducer prompt doesn't instruct the model to present the file holistically. **This is a real correctness bug** — it means file-level findings can be ghost-negative (fix applied, reviewer blind to it) and ghost-positive (gap exists in unreviewed shard, reviewer gives clean bill). Fix next loop: examine the reduce-step prompt in tree-split path, ensure accepted output comes from reduce step not from any individual shard. Validate by running on a file with a known fix and confirming the review notices it.
**[ITER 4 OBSERVATIONS]**
- **[🔴 OBSERVER DROPS SCRUM RICH FIELDS]** Scrum→observer wiring works (by_source.scrum appears in /stats on iter 4 file 1). BUT observer.ts:262-283 `ObservedOp = {...}` literal only spreads known keys (endpoint, success, duration_ms, role, city, state, count, rescue_*). My scrum-specific fields (confidence_avg, confidence_min, gradient_tier, verdict, critical_failures_count, verified_components_count, missing_components_count, alignment_score, output_format, findings_count, attempts_made, thin_rejections) are silently discarded. Observer knows the scrum event happened but loses review-quality data. Fix next loop: add `metadata?: Record<string, any>` passthrough on ObservedOp, or declare scrum-specific fields explicitly. Preferred: metadata passthrough so future sources (auditor, kb_extractor) land the same way.
- **[SCHEMA V4 LANDING CORRECTLY]** main.rs iter-4 KB row has alignment_score=3 (decimal parser fixed), output_format="markdown" (classifier works), verdict=null (correct — only forensic_json produces verdict), confidence_avg=91 (previous iter got 0 due to column dropout — run-to-run variance self-healed this). Structured counters (critical/verified/missing) = 0 on markdown rows, populated on forensic_json rows.
- **[RING BUFFER EVICTING LANGFUSE]** observer ring hit 2000 cap; first 2 scrum events pushed 2 langfuse entries out (1999 → 1997). Not a bug — ring works as designed — but means old-context retention is bounded. If we care about historical Langfuse traces we need a larger ring OR a separate per-source ring.
- **[UI Playwright probe found 2 real bugs]** (fixed 2026-04-24): (a) ui/server.ts tryFetch relied on content-type header to decide JSON vs text; observer Bun.serve returns JSON without `application/json` content-type, so stats were strings — UI showed "0 ops" instead of 2000. Fixed: always attempt JSON.parse, fall back to raw text. (b) ui.js renderNodeContext used Object.entries(n.health) which iterates characters on a string — gateway /health returns "lakehouse ok" and the panel showed rows like `0=l, 1=a, 2=k, ...`. Fixed: primitive-vs-object guard. **Both were invisible in functional tests — only a real browser render exposed them.** Worth adding a Playwright smoke test to CI for any future UI changes.
### Iter 1 results
*Populated after scrum finishes.*
## Iteration 2 — queued
**Prompt shape change (from J 2026-04-23):** iter 2+ uses `docs/SCRUM_FORENSIC_PROMPT.md` as the system prompt, replacing the softer iter-1 framing. Adversarial auditor tone with 8 audit passes. Strict JSON output format with `verdict: pass|fail|needs_patch`. If system can't prove itself, verdict is FAIL.
**Scrum pipeline change:** `scrum_master_pipeline.ts` needs an env `LH_SCRUM_SYSTEM_PROMPT` (new) to inject the forensic frame alongside the proposal doc. The file-level loop still asks for suggestions per file but under the 8-pass adversarial lens.
**Goal:** Self-host. Pipeline loads its ladder from `config/routing.toml` via the RoutingEngine that iter 1 wired. If that still isn't loaded, note gap, proceed with hardcoded ladder, flag for iter 3.
**Target expansion:** beyond `.rs` to `.ts` (tests/multi-agent, auditor/), `.py` (sidecar), `.md` (docs).
## Iterations 3-6 — queued
**Goal:** measure trajectory. Each iteration reduces finding count, raises unit test count, reduces grep-for-fake-markers count. If any iteration doesn't improve, that's the data point.
## Metrics per iteration
Capture after each re-sweep:
- `findings_total` (baseline: 19)
- `findings_by_severity` (baseline: 3h / 8m / 8l)
- `phases_partial_count` (baseline: 9)
- `phases_real_count` (baseline: 25 of 35)
- `rust_test_count` (baseline: 194+)
- `gateway_test_fail_count` (baseline: 1 — P38-001)
- `grep_hits_unimplemented` run: `grep -rEc 'todo!\(\)|unimplemented!\(\)|FIXME' crates/`
- `grep_hits_pseudo` run: `grep -rEc '\"placeholder\"|\"stub\"|\"mock\"|\"fake\"' crates/`
## Rules for this loop
1. **Cloud-first for every iteration.** Per feedback_scrum_cloud_first.md, strategic review uses 120B+ tier.
2. **One cross-cutting PR per iteration when possible.** Meta-pattern from audit: identity+auth+access+journal+truth share a pipe. Fix them together.
3. **Build must be green before next iteration starts.** A broken build is evidence the last iteration regressed, not progressed.
4. **Log findings to the jsonl as new rows per iteration** with `sweep_id: phase_sweep_2026-04-23-iterN`. Never overwrite prior iteration's findings — the trajectory is the whole point.
5. **Don't fix things during an iteration.** Every observation goes into "Fix next loop" section above. Next iteration's scrum picks them up.
-275
View File
@@ -1,275 +0,0 @@
# Scrum Master Pipeline — Spec + Current State
**Status:** Active iteration on branch `scrum/auto-apply-19814` → PR #11 at git.agentview.dev/profit/lakehouse
**Last iter:** 9 (2026-04-24)
**Branch commit head:** `f4cff66` (ADR-021 Phase D fix)
This doc is the single handoff artifact for the scrum-master + auto-apply + pathway-memory loop built during 2026-04-24 sessions. A fresh Claude Code session reading this + `docs/DECISIONS.md` (ADR-020 and ADR-021 specifically) + `MEMORY.md` should have the same context as the session that wrote it.
## 1. What the loop is
An autonomous review-and-commit pipeline that:
1. **Scrum master** (`tests/real-world/scrum_master_pipeline.ts`) — walks a target-file list, asks a 9-rung escalation ladder of cloud models to produce a forensic audit against PRD + a change proposal doc, retries with learning context until acceptance, emits a structured review row.
2. **Pathway memory** (`crates/vectord/src/pathway_memory.rs`) — stores the full backtrack context of each review (attempts, KB chunks, flags, bug fingerprints) indexed by a narrow fingerprint (`task_class + file_prefix + signal_class`). On every new review, it prepends historical bug patterns as a preamble so the reviewer preempts recurrences. Retired pathways auto-exclude themselves from hot-swap eligibility.
3. **Auto-applier** (`tests/real-world/scrum_applier.ts`) — filters schema_v4 review rows by gradient_tier + confidence, asks `qwen3-coder:480b` for concrete `old_string/new_string` patches, runs `cargo check --workspace`, commits on green OR reverts on red/warning-count-up/rationale-mismatch.
4. **Observer** (`mcp-server/observer.ts`) — receives per-file `/event` emissions, escalates failure clusters to LLM Team via `/v1/chat` with `qwen3-coder:480b`.
5. **Auditor** (`auditor/audit.ts`) — external N=3 consensus re-check of scrum findings; writes to `data/_kb/audit_facts.jsonl`.
The guiding principle: **every KB write has a reader, every PR claim is diff-verifiable.**
## 2. The 9-rung ladder (cloud-first, strongest-model-first)
Defined in `tests/real-world/scrum_master_pipeline.ts` at `const LADDER`:
| # | Provider | Model | Role |
|---|---|---|---|
| 1 | ollama_cloud | `kimi-k2:1t` | flagship, 1T params |
| 2 | ollama_cloud | `qwen3-coder:480b` | coding specialist, 480B |
| 3 | ollama_cloud | `deepseek-v3.1:671b` | reasoning, 671B |
| 4 | ollama_cloud | `mistral-large-3:675b` | deep analysis, 675B |
| 5 | ollama_cloud | `gpt-oss:120b` | reliable workhorse |
| 6 | ollama_cloud | `qwen3.5:397b` | dense 397B, final thinker |
| 7 | openrouter | `openai/gpt-oss-120b:free` | free-tier rescue |
| 8 | openrouter | `google/gemma-3-27b-it:free` | fastest rescue |
| 9 | ollama | `qwen3.5:latest` | last-resort local |
**Each attempt is evaluated by `isAcceptable()`** (chars ≥ 3800 AND not a malformed JSON-only dump). On reject, the next rung sees a learning preamble with the prior rejection reason.
## 3. Tree-split reducer
Files larger than `FILE_TREE_SPLIT_THRESHOLD = 6000` bytes get chunked into `FILE_SHARD_SIZE = 3500`-byte shards. Each shard gets summarized via a fast rung, summaries are concatenated with internal `§N§` markers, then fed as a SCRATCHPAD to the reviewer. The `§N§` markers are stripped before the reviewer sees the merged context so it cannot claim "(shard 3)" in titles.
Bug regime this fixed: pre-tree-split iters had reviewers claim fields were "missing" because the field was past the 6KB context cutoff, not actually absent.
## 4. Schema v4 KB rows
`data/_kb/scrum_reviews.jsonl` — one row per accepted review. Fields:
```json
{
"file": "crates/queryd/src/service.rs",
"reviewed_at": "2026-04-24T11:06:56Z",
"accepted_model": "ollama_cloud/kimi-k2:1t",
"accepted_on_attempt": 1,
"attempts_made": 1,
"tree_split_fired": true,
"suggestions_preview": "<truncated-2000-char>",
"confidences_per_finding": [92, 90, 88, 85, 75],
"confidence_avg": 86,
"confidence_min": 75,
"findings_count": 5,
"gradient_tier": "dry_run", // auto ≥90 / dry_run ≥70 / simulation ≥50 / block <50
"gradient_tier_avg": "dry_run",
"alignment_score": 3, // 1-10 self-rated
"output_format": "forensic_json",
"verdict": "fail", // pass | needs_patch | fail
"critical_failures_count": 3,
"pseudocode_flags_count": 0,
"prd_mismatches_count": 4,
"missing_components_count": 6,
"verified_components_count": 2,
"risk_points_count": 3,
"schema_version": 4,
"scrum_master_reviewed": true,
// ADR-021 fields on pathway trace (NOT this row, see pathway_memory state.json)
"pathway_hot_swap_hit": false,
"pathway_id": null,
"pathway_similarity": null,
"pathway_success_rate": null,
"rungs_saved": 0
}
```
## 5. Applier hardened gates (landed 5e8d87b)
`tests/real-world/scrum_applier.ts` has **5 gates** between emitter output and commit. A patch must pass ALL:
1. **Confidence gate** — emitter's self-reported `confidence >= MIN_CONF` (default 90; 85 with relaxed env). Rejected patches log reason `confidence NN < MM`.
2. **Size gate** — max 6 lines changed per patch. Prevents cascading cross-file refactors.
3. **Cargo-green gate** — `cargo check --workspace` must pass. Red build → `git checkout -- file`.
4. **Warning-count gate** — workspace baseline warning count measured at start; after patch, new count must be `≤ baseline`. Catches unused-import additions and dead_code-after-allow-removed patterns. **THIS GATE CAUGHT 96b46cd's HashSet noise.**
5. **Rationale-diff token alignment** — rationale text must share at least one non-stopword token with the patch's new_string. Catches "Add destructive SQL filter" claims on `use tracing;` diffs.
Plus:
- **Deny-list**: `config/`, `ops/`, `auditor/`, `docs/`, `data/`, `mcp-server/`, `ui/`, `sidecar/`, `scripts/` can't be auto-applied (human review required).
- **Branch guard**: refuses to run on `main`.
- **Dry-run workspace revert**: in `COMMIT=0` mode, file is reverted after check regardless of outcome — no state pollution between runs.
Every decision logs to `data/_kb/auto_apply.jsonl` (action: `committed` / `build_red_reverted` / `warnings_increased_reverted` / `rationale_mismatch_reverted` / `all_rejected` / `no_patches` / `dry_run_would_commit`).
## 6. Pathway memory (ADR-021)
**Full spec: `docs/DECISIONS.md` ADR-021. Code: `crates/vectord/src/pathway_memory.rs`.**
Three-layer matrix index for compounding semantic-correctness signal:
### Fingerprint (narrow)
`pathway_id = SHA256(task_class + "|" + file_prefix + "|" + signal_class)` — first 2 path segments (`crates/queryd`) so related files in the same crate share pathways.
### Embedding (similarity vector)
32-bucket L2-normalized token hash. Tokens include: task_class, file_path, signal_class, per-attempt model+rung+accepted flag, KB chunk source_docs, observer class, bridge libraries, sub-pipeline calls, **semantic_flags**, and **bug_fingerprints (flag+pattern_key)**.
**TS and Rust implementations byte-match** — verified by smoke test showing cosine=1.0 on same input tokens. This is load-bearing for the TS-written traces to be searchable against the Rust-indexed space.
### Hot-swap gate (5-factor AND)
```
narrow_fingerprint_matches
AND audit_consensus.pass != false (null OK during bootstrap)
AND replay_count >= 3 (probation)
AND success_rate >= 0.80
AND NOT retired
AND similarity(query_vec, stored.pathway_vec) >= 0.90
```
Replay bookkeeping: on hot-swap, `replay_count++`; if the recommended model succeeded, `replays_succeeded++`; if `replay_count >= 3 AND success_rate < 0.80` → `retired = true` (sticky — prevents oscillation on noise).
### Semantic-correctness layer (ADR-021)
Each `PathwayTrace` carries:
- `semantic_flags: Vec<SemanticFlag>` — one of 9 variants: `UnitMismatch`, `TypeConfusion`, `NullableConfusion`, `OffByOne`, `StaleReference`, `PseudoImpl`, `DeadCode`, `WarningNoise`, `BoundaryViolation`
- `bug_fingerprints: Vec<BugFingerprint>` — `{flag, pattern_key, example, occurrences}` where `pattern_key = "{Flag}:{sorted-top-3-identifiers-joined-by-hyphen}"`. Stable across prose variation.
- `type_hints_used: Vec<TypeHint>` — `{source, symbol, type_repr}`. Phase E (not yet populated).
**Pre-review enrichment**: scrum calls `POST /vectors/pathway/bug_fingerprints` with `{task_class, file_path, signal_class, limit}` — returns aggregated fingerprints sorted by occurrences descending. If any, a `📚 PATHWAY MEMORY` preamble is prepended to the reviewer prompt with "this file area had these patterns before — check for recurrences."
**Post-review extractor** (Phase D, `scrum_master_pipeline.ts`): walks reviewer markdown line-by-line, finds lines containing a `SemanticFlag` variant, extracts identifier-shaped backtick-quoted tokens, filters out flag names + Rust keywords (self/mut/async/etc), sorts and takes top 3, builds `pattern_key = "{Flag}:{tokens}"`.
### HTTP surface (on gateway port 3100)
| Endpoint | Purpose |
|---|---|
| `POST /vectors/pathway/insert` | write a full PathwayTrace |
| `POST /vectors/pathway/query` | hot-swap candidate check (returns `{candidate: null}` or `{candidate: {...}}`) |
| `POST /vectors/pathway/record_replay` | update replay_count + success_rate after hot-swap |
| `GET /vectors/pathway/stats` | totals + reuse_rate + replay_success_rate |
| `POST /vectors/pathway/bug_fingerprints` | aggregated fingerprints by narrow fingerprint (for pre-review preamble) |
### State persistence
`data/_pathway_memory/state.json` — JSON dump of all buckets. Loaded at gateway boot (`crates/gateway/src/main.rs` has `pwm.load_from_storage().await`).
## 7. Current state (2026-04-24 end of session)
### Commits on branch `scrum/auto-apply-19814` since iter-5 baseline
| # | SHA | Subject |
|---|---|---|
| 1 | `25ea3de` | observer fix — route LLM Team escalation to `/v1/chat` qwen3-coder |
| 2 | `8b77d67` | OpenRouter rescue ladder + tree-split reducer + first auto-applier |
| 3 | `96b46cd` | first auto-applied commit (later found misleading) |
| 4 | `5e8d87b` | cleanup + applier hardening (warning + rationale + dry-run gates) |
| 5 | `9cc0ceb` | P42-002 — truth gate into queryd `/sql` + `/paged` paths |
| 6 | `2f8b347` | pathway_memory base (PathwayTrace, hot-swap, 18 tests) |
| 7 | `86901f8` | queryd/delta.rs 6-line unit-mismatch fix |
| 8 | `92df0e9` | ADR-021 spec |
| 9 | `0a0843b` | ADR-021 Phases A+B+C (semantic_flags, prompt tags, preamble endpoint) |
| 10 | `ee31424` | ADR-021 Phase D (fingerprint extractor) |
| 11 | `f4cff66` | Phase D fix — strip flag names + Rust keywords from pattern_keys |
### Matrix index state
- **12 pathway traces** in `data/_pathway_memory/state.json`
- **11 distinct bug fingerprints** across 4 Flag categories on `crates/queryd` narrow fingerprint (1 manually seeded + 10 extracted)
- **0 hot-swaps fired** (probation requires ≥3 replays per pathway; none reached yet)
### Active in-flight
- Iter 9 complete; next iter 10+ will use cleaner fingerprint extractor (`f4cff66`)
- 4 "noisy" pattern_keys from iter-9-file-1 pre-fix run (e.g., `DeadCode:DeadCode`) — dormant, won't match future output, acceptable dead entries
### Queued (not yet implemented)
- **Phase E** — `type_hints_used` population from `catalogd` column types, Arrow `RecordBatch.schema()`, Rust struct field types. Feeds typed context to reviewer prompt.
- **Auditor → pathway audit_consensus wire** — activates the strict-audit gate (currently lenient: null bootstraps, only explicit `false` blocks).
- **VCP UI cards** for "top bug fingerprints in last N iters" + "new patterns learned this iter"
## 8. How to run a new iteration
```bash
# Default 3 files (playbook_memory.rs, doc_drift.rs, auditor/audit.ts)
LH_SCRUM_FORENSIC=/home/profit/lakehouse/docs/SCRUM_FORENSIC_PROMPT.md \
LH_SCRUM_PROPOSAL=/home/profit/lakehouse/docs/SCRUM_FIX_WAVE.md \
bun run tests/real-world/scrum_master_pipeline.ts
# Targeted files:
LH_SCRUM_FILES="/home/profit/lakehouse/crates/queryd/src/delta.rs,/home/profit/lakehouse/crates/queryd/src/service.rs" \
LH_SCRUM_FORENSIC=... LH_SCRUM_PROPOSAL=... \
bun run tests/real-world/scrum_master_pipeline.ts
# Dry-run auto-applier against the latest scrum output:
LH_APPLIER_MIN_CONF=85 LH_APPLIER_MAX_FILES=10 \
LH_APPLIER_MODEL=qwen3-coder:480b \
LH_APPLIER_BRANCH=scrum/auto-apply-19814 \
bun run tests/real-world/scrum_applier.ts
# Actually commit (ONLY after dry-run looks clean):
LH_APPLIER_COMMIT=1 LH_APPLIER_MIN_CONF=85 LH_APPLIER_MAX_FILES=10 \
LH_APPLIER_MODEL=qwen3-coder:480b \
LH_APPLIER_BRANCH=scrum/auto-apply-19814 \
bun run tests/real-world/scrum_applier.ts
```
## 9. Verify services before running
```bash
# Gateway (port 3100) — must be up; pathway endpoints are here
curl -s http://localhost:3100/health # "lakehouse ok"
curl -s http://localhost:3100/vectors/pathway/stats # pathway memory totals
# UI (port 3950) — VCP dashboard + /data/pathway_stats aggregation
curl -s http://localhost:3950/data/pathway_stats
# Observer (port 3800) — event receiver + LLM Team escalation
curl -s http://localhost:3800/health 2>/dev/null || true
# Sidecar (port 3200) — Python embed
curl -s http://localhost:3200/health 2>/dev/null || true
# LLM Team (port 5000) — /api/run?mode=extract ONLY registered mode
# (others like code_review/patch/refactor return "Unknown mode")
curl -s http://localhost:5000/health 2>/dev/null || true
```
If gateway missing new routes after code change: `cargo build --release -p gateway && sudo systemctl restart lakehouse.service`.
If UI missing new routes: kill old `bun run ui/server.ts` and restart (not a systemd service right now).
## 10. Where things live (code pointers)
| Concern | File |
|---|---|
| Scrum orchestrator | `tests/real-world/scrum_master_pipeline.ts` |
| Scrum ladder constant | same file, `const LADDER` line ~92 |
| Tree-split reducer | same file, `async function treeSplitFile` |
| Forensic prompt preamble (loaded via env) | `docs/SCRUM_FORENSIC_PROMPT.md` |
| Fix-wave proposal preamble | `docs/SCRUM_FIX_WAVE.md` |
| Scrum iter notes | `docs/SCRUM_LOOP_NOTES.md` |
| Auto-applier | `tests/real-world/scrum_applier.ts` |
| Applier audit trail | `data/_kb/auto_apply.jsonl` |
| Scrum reviews KB | `data/_kb/scrum_reviews.jsonl` |
| Model trust journal | `data/_kb/model_trust.jsonl` |
| Pathway memory module | `crates/vectord/src/pathway_memory.rs` |
| Pathway HTTP handlers | `crates/vectord/src/service.rs` (bottom) |
| Pathway state on disk | `data/_pathway_memory/state.json` |
| VCP UI server | `ui/server.ts` |
| VCP UI client | `ui/ui.js` + `ui/ui.css` + `ui/index.html` |
| Observer | `mcp-server/observer.ts` |
| Auditor | `auditor/audit.ts` |
| LLM Team extract client | `auditor/fact_extractor.ts` |
| ADR-021 spec | `docs/DECISIONS.md` ADR-021 |
## 11. Key memory files a fresh session should read
From `/root/.claude/projects/-home-profit/memory/`:
- `project_scrum_pipeline.md` — updated state of the scrum iterations
- `project_first_auto_apply.md` — 96b46cd story + cleanup + hardening evidence from iter 7
- `feedback_semantic_correctness_via_matrix.md` — J's insight on compounding, the ADR-021 rule
- `feedback_endpoint_probe_discipline.md` — GET 405 is not endpoint validation
- `reference_llm_team_modes.md` — only `extract` is registered on port 5000
- `feedback_scrum_cloud_first.md` — scrum/audit/enrich pipelines use cloud first
- `feedback_cloud_determinism.md` — cloud N=3 consensus + qwen3-coder tie-breaker
## 12. Known gotchas
- **Gateway restart needed after Rust route additions.** `sudo systemctl restart lakehouse.service` — the service is systemd-managed.
- **UI server needs manual restart** after `ui/server.ts` changes (no systemd unit). Kill old `bun` pid, restart with `bun run ui/server.ts &`.
- **LLM Team mode `code_review` doesn't exist** — only `extract` is registered in `/root/llm_team_ui.py`. Don't wire new features to "Unknown mode" endpoints. See `reference_llm_team_modes.md`.
- **OpenRouter free-tier 429s during consensus probes** are normal (rate-limited upstream). In the production ladder they hit as last-resort rescue with seconds-to-minutes gap; different traffic pattern than rapid-fire consensus runs.
- **Openrouter minimax-m2.5:free has a 45s timeout** — not in ladder, only for one-off probes.
- **Probation period is 3 replays** before hot-swap can fire. On a fresh install, no hot-swap fires until a pathway has been re-visited ≥3 times.
-83
View File
@@ -1,83 +0,0 @@
# Future Expansion — Advanced System Evolution Layers
Adopted 2026-04-24 from J. The system stops optimizing for task completion. It optimizes for **provable execution, repeatable outcomes, resilience under drift, failure, and adversarial conditions.**
## Layer roster + iteration mapping
| # | Layer | Short form | Target iter |
|---|---|---|---:|
| 1 | Counterfactual Execution | Generate synthetic failure variants from each success | iter 5 |
| 2 | Model Trust Profiling | Per-(model, task_type) success rate → routing weight | **iter 3** |
| 3 | Execution DNA | Compress successful runs into reusable patterns | iter 4 |
| 4 | Drift Sentinel | Re-validate historical tasks on a schedule | iter 5 |
| 5 | Adversarial Injection | Inject poisoned context / malformed outputs / conflicts | iter 6 |
| 6 | Permission Gradient | Confidence → execution tier (≥0.9 full, ≥0.7 dry-run, ≥0.5 sim, <0.5 block) | **iter 3** |
| 7 | Multi-Agent Disagreement | Planner/Critic/Validator — disagreement = signal | iter 4 |
| 8 | Temporal Context | Time-aware memory with decay_score + last_validated_at | iter 4 |
| 9 | Execution Cost Intelligence | Tokens, iterations, cloud_calls, latency per task | **iter 3** |
| 10 | Human Override as Data | Capture manual fixes as jsonl rows | **iter 3** |
## Detail (J's original framing preserved)
### 1. Counterfactual Execution Layer
Simulate alternate failure paths for every successful task. Real Execution → Success → Generate Variations (env, version, inputs) → Simulate Failure Cases → Store Synthetic Failure Signatures. **Purpose:** pre-train against unseen failures before real exposure.
### 2. Model Trust Profiling ← iter 3
Per-(model, task_type) performance tracking.
```
{ "model": "...", "task_type": "...", "success_rate": 0.0, "failure_modes": [], "trust_score": 0.0 }
```
**Usage:** route by trust score, adjust validation strictness dynamically, per-model risk budgets.
### 3. Execution DNA (Trace Compression)
Successful executions → reusable fragments.
```
{ "dna_id": "hash", "task_signature": "...", "critical_steps": [], "failure_avoidance": [] }
```
Replaces doc retrieval with pattern retrieval; faster convergence on similar tasks.
### 4. Drift Sentinel
Select Historical Task → Re-run Current Env → Compare → If Failure → Mark Drifted → Trigger Re-learning. Detect silent decay; maintain long-term reliability.
### 5. Adversarial Injection Engine
Inject malformed outputs / outdated docs / conflicting instructions / poisoned memory. Verify validation catches, execution blocks unsafe actions, memory rejects corrupted data. Build system immunity.
### 6. Permission Gradient Execution ← iter 3
Confidence-based control replacing binary:
- confidence ≥ 0.9 → full execution
- confidence ≥ 0.7 → dry-run + diff
- confidence ≥ 0.5 → simulation only
- confidence < 0.5 → block
Inputs: validation score, model trust score, memory match confidence. Risk-aware control; reduced catastrophic-failure surface.
### 7. Multi-Agent Disagreement Engine
Planner / Critic / Validator; disagreement triggers more context, bigger model, stricter validation. Disagreement is signal, not noise.
### 8. Temporal Context Layer
```
{ "created_at": "ts", "last_validated_at": "ts", "decay_score": 0.0 }
```
Retrieval priority: recent + validated + high success rate. Avoid stale knowledge.
### 9. Execution Cost Intelligence ← iter 3
```
{ "task": "...", "tokens_used": 0, "iterations": 0, "cloud_calls": 0, "latency_ms": 0 }
```
Optimize local vs cloud; reduce unnecessary iterations.
### 10. Human Override as Data ← iter 3
```
{ "human_fix": "...", "reason": "...", "task_signature": "...", "validated": true }
```
Manual fixes become reusable knowledge.
## Final Principle
Memory is not passive recall. It is operational substrate:
- failures become structured knowledge
- successes become reusable execution patterns
- all outputs are validated before reuse
## System Directive
Not speed. Not convenience. **Correctness. Verifiability. Resilience under change.**
+25 -289
View File
@@ -18,7 +18,6 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
import { z } from "zod";
import { startTrace, logSpan, logGeneration, scoreTrace, flush as flushTraces } from "./tracing.js";
import { buildPermitBrief } from "./entity.js";
const BASE = process.env.LAKEHOUSE_URL || "http://localhost:3100";
const PORT = parseInt(process.env.MCP_PORT || "3700");
@@ -40,29 +39,6 @@ async function api(method: string, path: string, body?: any, retries = 2) {
const ms = Date.now() - t0;
let parsed: any;
try { parsed = JSON.parse(text); } catch { parsed = { raw: text, status: resp.status }; }
// Trace the call if we have an active trace. Pre-existing edit had
// this block at module scope, dangling after the closing brace of
// api() — parsed broken until fixed 2026-04-24.
if (activeTrace) {
const isGen = path.includes("/generate");
if (isGen) {
logGeneration(activeTrace, `lakehouse${path}`, {
model: body?.model || "unknown",
prompt: typeof body?.prompt === "string" ? body.prompt.slice(0, 500) : JSON.stringify(body).slice(0, 300),
completion: typeof parsed?.text === "string" ? parsed.text.slice(0, 500) : JSON.stringify(parsed).slice(0, 300),
duration_ms: ms,
tokens_in: parsed?.prompt_eval_count,
tokens_out: parsed?.eval_count,
});
} else {
logSpan(activeTrace, `lakehouse${path}`, body, {
rows: parsed?.row_count, sources: parsed?.sources?.length,
sql_matches: parsed?.sql_matches, method: parsed?.method,
}, ms);
}
}
return parsed;
} catch (e: any) {
if (attempt === retries) throw e;
@@ -76,6 +52,29 @@ async function api(method: string, path: string, body?: any, retries = 2) {
throw new Error("unreachable");
}
// Trace the call if we have an active trace
if (activeTrace) {
const isGen = path.includes("/generate");
if (isGen) {
logGeneration(activeTrace, `lakehouse${path}`, {
model: body?.model || "unknown",
prompt: typeof body?.prompt === "string" ? body.prompt.slice(0, 500) : JSON.stringify(body).slice(0, 300),
completion: typeof parsed?.text === "string" ? parsed.text.slice(0, 500) : JSON.stringify(parsed).slice(0, 300),
duration_ms: ms,
tokens_in: parsed?.prompt_eval_count,
tokens_out: parsed?.eval_count,
});
} else {
logSpan(activeTrace, `lakehouse${path}`, body, {
rows: parsed?.row_count, sources: parsed?.sources?.length,
sql_matches: parsed?.sql_matches, method: parsed?.method,
}, ms);
}
}
return parsed;
}
const server = new McpServer({ name: "lakehouse", version: "1.0.0" });
server.tool(
@@ -961,61 +960,6 @@ async function main() {
return new Response(Bun.file(import.meta.dir + "/console.html"));
}
// ─── Contractor / entity drill-down page ───
// Single-contractor portfolio view across every wired source:
// OSHA national, Chicago history, ticker chart, parent link,
// federal contracts, debarment, unions, training. Click any
// contractor name in a permit Entity Brief to land here.
if (url.pathname === "/contractor") {
return new Response(Bun.file(import.meta.dir + "/contractor.html"), {
headers: { ...cors, "Content-Type": "text/html" },
});
}
if (url.pathname === "/intelligence/contractor_profile" && req.method === "POST") {
const start = Date.now();
try {
const b = (await req.json().catch(() => ({}))) as { name?: string };
if (!b.name) return err("missing name", 400);
// Use the entity-brief library directly — single entity, all sources.
const { fetchOshaBrief, fetchTickerBrief, fetchContractorHistory, fetchParentLink, fetchFederalContracts, fetchDebarmentBrief, fetchNlrbBriefReal, fetchIlsosBrief, fetchNewsMentions, fetchDiversityCerts, scoreNewsSentiment, fetchBlsConstructionTrend, normalizeEntityName, entityTicker } = await import("./entity.js");
const [osha, stock, history, parent_link, federal, debarment, nlrb, ilsos, news, diversity, macro] = await Promise.all([
fetchOshaBrief(b.name),
fetchTickerBrief(b.name),
fetchContractorHistory(b.name),
fetchParentLink(b.name),
fetchFederalContracts(b.name),
fetchDebarmentBrief(b.name),
fetchNlrbBriefReal(b.name),
fetchIlsosBrief(b.name),
fetchNewsMentions(b.name),
fetchDiversityCerts(b.name),
fetchBlsConstructionTrend(),
]);
const news_sentiment = news ? scoreNewsSentiment(news) : null;
return ok({
key: normalizeEntityName(b.name),
display_name: b.name,
ticker: entityTicker(b.name),
osha,
stock,
history,
parent_link,
federal,
debarment,
nlrb,
ilsos,
news,
news_sentiment,
diversity,
macro,
generated_at: new Date().toISOString(),
duration_ms: Date.now() - start,
});
} catch (e: any) {
return err(`contractor_profile: ${e.message}`, 500);
}
}
// Intelligence: Market data — public building permits → staffing demand forecast
if (url.pathname === "/intelligence/market" && req.method === "POST") {
const start = Date.now();
@@ -1214,86 +1158,14 @@ async function main() {
// a PROPOSED fill drawn from our 500K worker bench. Surfaces the
// meta-index dimension directly: "what past similar fills had in
// common" for this role + geo.
// Architecture signals — the "our substrate is better than the
// alternatives" proof surface. Pulls live health numbers so the
// dashboard can show, per-card or in a top bar, that the claims
// we make in the PRD (instant searches, self-regulation,
// hot-swap, indexed-at-ingest) are verifiable right now.
if (url.pathname === "/intelligence/arch_signals" && (req.method === "GET" || req.method === "POST")) {
try {
const t0 = Date.now();
// Index freshness + shape (hot-swap + clever-index claims)
const idxRaw = await fetch("http://localhost:3100/vectors/indexes/workers_500k_v1", {
signal: AbortSignal.timeout(3000),
}).then(r => r.ok ? r.json() : null).catch(() => null);
// Playbook memory — "self-regulates via learned playbooks"
const pbmRaw = await fetch("http://localhost:3100/vectors/playbook_memory/stats", {
signal: AbortSignal.timeout(3000),
}).then(r => r.ok ? r.json() : null).catch(() => null);
// Pathway memory — ADR-021 compounding-bug-grammar surface
const pwmRaw = await fetch("http://localhost:3100/vectors/pathway/stats", {
signal: AbortSignal.timeout(3000),
}).then(r => r.ok ? r.json() : null).catch(() => null);
// Live instant-search probe — one trivial hybrid call so the
// latency number on screen is fresh, not cached.
const probeT0 = Date.now();
await api("POST", "/vectors/hybrid", {
index_name: "workers_500k_v1",
filter_dataset: "workers_500k",
id_column: "worker_id",
sql_filter: "state = 'OH'",
question: "production worker",
top_k: 3, generate: false,
}).catch(() => ({}));
const probeMs = Date.now() - probeT0;
return ok({
generated_at: new Date().toISOString(),
duration_ms: Date.now() - t0,
index: idxRaw ? {
name: idxRaw.index_name,
source: idxRaw.source,
model: idxRaw.model_name,
dimensions: idxRaw.dimensions,
chunk_count: idxRaw.chunk_count,
doc_count: idxRaw.doc_count,
created_at: idxRaw.created_at,
backend: idxRaw.vector_backend,
last_used: idxRaw.last_used ?? null,
build_signature: idxRaw.build_signature ?? null,
} : null,
playbook_memory: pbmRaw ? {
entries: pbmRaw.entries_count ?? pbmRaw.count ?? 0,
rebuilt_at: pbmRaw.last_rebuilt_at ?? null,
} : null,
pathway_memory: pwmRaw ? {
total_pathways: pwmRaw.total_pathways ?? 0,
retired: pwmRaw.retired ?? 0,
with_audit_pass: pwmRaw.with_audit_pass ?? 0,
total_replays: pwmRaw.total_replays ?? 0,
} : null,
instant_search_probe_ms: probeMs,
});
} catch (e: any) {
return err(`arch_signals: ${e.message}`, 500);
}
}
if (url.pathname === "/intelligence/permit_contracts" && req.method === "POST") {
const start = Date.now();
try {
const permitUrl = "https://data.cityofchicago.org/resource/ydr8-5enu.json";
// Recent + substantial permits only — skip tiny ones that
// don't imply real staffing demand.
// Include contact_1 + contact_2 fields so the Entity Brief
// panel on each card can populate without a second fetch.
// Contacts identify the applicant / contractor by name —
// those are the keys we pass to OSHA/ILSOS enrichment.
const permits: any[] = await fetch(
`${permitUrl}?$select=id,permit_type,work_type,work_description,reported_cost,street_number,street_direction,street_name,community_area,issue_date,contact_1_name,contact_1_type,contact_2_name,contact_2_type&`
`${permitUrl}?$select=permit_type,work_type,work_description,reported_cost,street_number,street_direction,street_name,community_area,issue_date&`
+ `$where=reported_cost>250000 AND issue_date>'2025-06-01'`
+ `&$order=issue_date DESC&$limit=6`
).then(r => r.json()).catch(() => []);
@@ -1321,11 +1193,6 @@ async function main() {
// query path exactly. k=200 to ensure boost fires across
// the full memory surface (the embedding-discrimination
// narrowness means under-k silently misses endorsements).
//
// Timed so the UI can surface "instant search from clever
// indexing at ingest" — the architecture claim J wants
// visible. Each contract card shows its hybrid latency.
const hybridT0 = Date.now();
const searchRes = await api("POST", "/vectors/hybrid", {
index_name: "workers_500k_v1",
filter_dataset: "workers_500k",
@@ -1335,7 +1202,6 @@ async function main() {
top_k: 5, generate: false,
use_playbook_memory: true, playbook_memory_k: 200,
}).catch(() => ({ sources: [] as any[] }));
const hybridMs = Date.now() - hybridT0;
// Path 2 — discovered patterns for this role in this city.
const patternRes = await api("POST", "/vectors/playbook_memory/patterns", {
@@ -1374,72 +1240,14 @@ async function main() {
else if (daysToDeadline <= 21) urgency = "soon";
else urgency = "scheduled";
// Fill-probability ramp — staffing-industry heuristic.
// Base probability by pool_size (how many available workers
// match the role+geo), decayed by days-remaining. Produces
// a curve the UI can sparkline.
const poolSize = (searchRes.sql_matches ?? 0) as number;
const basePFill = poolSize >= count * 20 ? 0.95
: poolSize >= count * 10 ? 0.85
: poolSize >= count * 5 ? 0.70
: poolSize >= count * 2 ? 0.55
: poolSize >= count ? 0.35
: 0.15;
const fillByDay = [0, 3, 7, 14, 21, 30].map((d) => {
// Front-loaded: most fills land in first 7 days; tail
// falls off quickly. This is a Weibull-ish shape that
// matches real staffing data we've seen.
const ramp = d === 0 ? 0.0
: d <= 3 ? 0.35
: d <= 7 ? 0.65
: d <= 14 ? 0.85
: d <= 21 ? 0.95
: 1.0;
return { day: d, cumulative_pct: Math.round(basePFill * ramp * 100) };
});
// Economics — "as though the contracts were accepted and
// filled." 40 hrs/week, default 12-week contract. Margin
// = (bill - avg_pay) × count × hours. Payout window is
// fill_date + 30d billing cycle.
const weeksAssumed = 12;
const hoursPerWeek = 40;
const avgPayRate = sources.length
? sources.reduce((s, c) => s + (c.implied_pay_rate || 0), 0) / sources.length
: contractBillRate / BILL_MARKUP;
const grossRevenue = contractBillRate * count * hoursPerWeek * weeksAssumed;
const grossMargin = (contractBillRate - avgPayRate) * count * hoursPerWeek * weeksAssumed;
const overBillCount = sources.filter((c) => c.over_bill_rate).length;
const overBillPoolMargin = sources
.filter((c) => c.over_bill_rate)
.reduce((s, c) => s + (c.implied_pay_rate - contractBillRate) * hoursPerWeek * weeksAssumed, 0);
// Shift inference from permit work_type + description.
// Construction defaults to 1st-shift (day). Heavy civil or
// facility work sometimes runs 2nd or split-shift. 3rd
// (overnight) is rare in commercial construction but real
// for maintenance / emergency calls.
const descLower = ((p.work_description || "") + " " + (p.work_type || "")).toLowerCase();
const shifts: string[] = ["1st"]; // default day
if (/night|overnight|24\s*hr|emergency/.test(descLower)) shifts.push("3rd");
if (/multi.?shift|round.?the.?clock|double.?shift/.test(descLower)) shifts.push("2nd");
if (/weekend|saturday|sunday/.test(descLower)) shifts.push("4th");
contracts.push({
permit: {
id: p.id,
cost,
work_type: p.work_type || "General construction",
description: (p.work_description || "").substring(0, 140),
address: `${p.street_number || ""} ${p.street_direction || ""} ${p.street_name || ""}`.trim(),
community_area: p.community_area,
issue_date: (p.issue_date || "").substring(0, 10),
// Contacts — used by /intelligence/permit_entities to
// enrich each card with OSHA + ILSOS on expand.
contact_1_name: p.contact_1_name || "",
contact_1_type: p.contact_1_type || "",
contact_2_name: p.contact_2_name || "",
contact_2_type: p.contact_2_type || "",
},
implied_bill_rate: contractBillRate,
timeline: {
@@ -1452,32 +1260,12 @@ async function main() {
role,
count,
city, state,
pool_size: poolSize,
pool_size: searchRes.sql_matches,
candidates: sources,
},
discovered_pattern: patternRes.discovered_pattern,
pattern_matched: patternRes.matched_playbooks ?? 0,
pattern_workers_examined: patternRes.total_workers_examined ?? 0,
// ADR-021 / PRD architecture claims surface — these fields
// let the UI show "instant search from clever indexing"
// and the fill economics beyond bill rate alone.
search_latency_ms: hybridMs,
fill_probability: {
base_pct: Math.round(basePFill * 100),
curve: fillByDay,
},
economics: {
avg_pay_rate: Math.round(avgPayRate * 100) / 100,
hours_per_week: hoursPerWeek,
weeks_assumed: weeksAssumed,
gross_revenue: Math.round(grossRevenue),
gross_margin: Math.round(grossMargin),
margin_pct: grossRevenue > 0 ? Math.round((grossMargin / grossRevenue) * 100) : 0,
payout_window_days: [30, 45],
over_bill_count: overBillCount,
over_bill_pool_margin_at_risk: Math.round(overBillPoolMargin),
},
shifts_needed: shifts,
});
}
@@ -1493,58 +1281,6 @@ async function main() {
}
}
// Intelligence: per-permit entity brief — OSHA + ILSOS + property
// Takes a permit identifier (we look it up from Chicago Socrata) or
// raw contact fields directly from the client. Returns an "ETF
// basket" shape: property + entities + per-entity risk factors.
// OSHA is live-scraped (cached 30d). ILSOS returns a structured
// placeholder because apps.ilsos.gov blocks our ASN.
if (url.pathname === "/intelligence/permit_entities" && req.method === "POST") {
const start = Date.now();
try {
const b = await req.json().catch(() => ({})) as {
permit_id?: string;
address?: string;
work_type?: string;
contact_1_name?: string;
contact_1_type?: string;
contact_2_name?: string;
contact_2_type?: string;
fetch_osha?: boolean;
fetch_ilsos?: boolean;
};
// If the caller didn't pass contact fields but did pass a
// permit_id, go pull the record from Chicago Socrata.
let permit = b;
if (b.permit_id && !b.contact_1_name) {
const u = `https://data.cityofchicago.org/resource/ydr8-5enu.json?$where=id='${encodeURIComponent(b.permit_id)}'`;
const rows = (await fetch(u).then((r) => r.json())) as any[];
const p = rows?.[0];
if (p) {
const addr = [p.street_number, p.street_direction, p.street_name]
.filter(Boolean)
.join(" ");
permit = {
permit_id: b.permit_id,
address: addr,
work_type: p.work_type,
contact_1_name: p.contact_1_name,
contact_1_type: p.contact_1_type,
contact_2_name: p.contact_2_name,
contact_2_type: p.contact_2_type,
};
}
}
const brief = await buildPermitBrief(permit, {
fetchOsha: b.fetch_osha !== false,
fetchIlsos: b.fetch_ilsos !== false,
});
return ok({ ...brief, duration_ms: Date.now() - start });
} catch (e: any) {
return err(`permit_entities: ${e.message}`, 500);
}
}
// Removed 2026-04-20: /intelligence/learn was a legacy CSV writer
// that destructively re-wrote successful_playbooks. /log and
// /log_failure replace it cleanly via /vectors/playbook_memory/seed
-174
View File
@@ -1,174 +0,0 @@
// langfuse_bridge — the missing piece called out in project_lost_stack.md
// and Phase 40 PRD. Polls Langfuse `/api/public/traces` at interval,
// forwards every completed trace to observer `:3800/event` with
// `source: "langfuse"`. Observer's existing ring buffer + analyzer
// pick it up, so the KB learns from cost/latency/provider deltas per
// model — not just from scenario outcomes.
//
// Loopback: observer persistOp() appends to data/_observer/ops.jsonl
// and its aggregator produces pathway_recommendations.jsonl. This
// bridge closes the feedback loop between LLM call metadata and the
// playbook/KB learning surface.
//
// State persistence: last-seen trace timestamp written to a JSON file
// so restarts don't double-emit. Bounded forward window (50/tick) so
// first-run catch-up doesn't hammer the observer.
const LANGFUSE_URL = process.env.LANGFUSE_URL ?? "http://localhost:3000";
const LANGFUSE_PUBLIC = process.env.LANGFUSE_PUBLIC_KEY;
const LANGFUSE_SECRET = process.env.LANGFUSE_SECRET_KEY;
const OBSERVER_URL = process.env.OBSERVER_URL ?? "http://localhost:3800";
const POLL_INTERVAL_MS = Number(process.env.LANGFUSE_POLL_MS ?? 30000);
const BATCH_LIMIT = Number(process.env.LANGFUSE_BATCH_LIMIT ?? 50);
const STATE_FILE = process.env.LANGFUSE_STATE_FILE
?? "/var/lib/lakehouse-guard/langfuse_last_seen.json";
interface LangfuseTrace {
id: string;
name?: string;
timestamp: string;
input?: any;
output?: any;
latency?: number; // seconds, per Langfuse API
totalCost?: number;
usage?: { input?: number; output?: number; total?: number };
metadata?: any;
}
interface State { last_seen_ts?: string }
function basicAuth(): string {
return "Basic " + btoa(`${LANGFUSE_PUBLIC}:${LANGFUSE_SECRET}`);
}
async function loadState(): Promise<State> {
try {
const f = Bun.file(STATE_FILE);
if (!(await f.exists())) return {};
return JSON.parse(await f.text()) as State;
} catch (e) {
console.warn(`[langfuse-bridge] state load failed: ${e}`);
return {};
}
}
async function saveState(s: State): Promise<void> {
try {
await Bun.write(STATE_FILE, JSON.stringify(s));
} catch (e) {
console.warn(`[langfuse-bridge] state save failed: ${e}`);
}
}
async function fetchTracesSince(cursor?: string): Promise<LangfuseTrace[]> {
const url = new URL("/api/public/traces", LANGFUSE_URL);
url.searchParams.set("limit", String(BATCH_LIMIT));
url.searchParams.set("orderBy", "timestamp.asc");
if (cursor) url.searchParams.set("fromTimestamp", cursor);
const resp = await fetch(url, {
headers: { authorization: basicAuth() },
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
throw new Error(`langfuse ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
}
const body: any = await resp.json();
return (body.data ?? []) as LangfuseTrace[];
}
// Shape one Langfuse trace into the ObservedOp the observer expects
// (see mcp-server/observer.ts:29). `source: "langfuse"` is the
// provenance flag so the analyzer can weight traces differently from
// scenario-sourced events.
function toObservedOp(t: LangfuseTrace): Record<string, any> {
const endpoint = t.metadata?.provider
?? t.metadata?.model
?? t.name
?? "langfuse.trace";
const inputSummary = typeof t.input === "string"
? t.input.slice(0, 200)
: JSON.stringify(t.input ?? {}).slice(0, 200);
const outputSummary = typeof t.output === "string"
? t.output.slice(0, 200)
: JSON.stringify(t.output ?? {}).slice(0, 200);
return {
timestamp: t.timestamp,
endpoint: `langfuse:${endpoint}`,
input_summary: inputSummary,
success: !t.metadata?.error,
duration_ms: Math.round((t.latency ?? 0) * 1000),
output_summary: outputSummary,
source: "langfuse",
sig_hash: t.metadata?.sig_hash,
event_kind: t.metadata?.task_class,
// Extra fields the observer doesn't schema but the KB aggregator
// can still pick up via JSON passthrough.
model: t.metadata?.model,
provider: t.metadata?.provider,
prompt_tokens: t.usage?.input,
completion_tokens: t.usage?.output,
total_tokens: t.usage?.total,
total_cost: t.totalCost,
};
}
async function forwardToObserver(op: Record<string, any>): Promise<void> {
const resp = await fetch(`${OBSERVER_URL}/event`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(op),
signal: AbortSignal.timeout(5_000),
});
if (!resp.ok) {
throw new Error(`observer ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
}
}
async function tick(): Promise<void> {
const state = await loadState();
let traces: LangfuseTrace[];
try {
traces = await fetchTracesSince(state.last_seen_ts);
} catch (e) {
console.warn(`[langfuse-bridge] fetch failed: ${e}`);
return;
}
if (traces.length === 0) {
console.log(`[langfuse-bridge] no new traces since ${state.last_seen_ts ?? "start"}`);
return;
}
let last = state.last_seen_ts ?? "";
let forwarded = 0;
for (const t of traces) {
try {
await forwardToObserver(toObservedOp(t));
forwarded++;
if (t.timestamp > last) last = t.timestamp;
} catch (e) {
console.warn(`[langfuse-bridge] forward ${t.id} failed: ${e}`);
// Don't advance cursor on forward failure — retry next tick.
break;
}
}
if (last) await saveState({ last_seen_ts: last });
console.log(
`[langfuse-bridge] forwarded ${forwarded}/${traces.length}, last_seen=${last}`,
);
}
async function main(): Promise<void> {
if (!LANGFUSE_PUBLIC || !LANGFUSE_SECRET) {
console.error("LANGFUSE_PUBLIC_KEY + LANGFUSE_SECRET_KEY required");
process.exit(1);
}
console.log(
`[langfuse-bridge] polling ${LANGFUSE_URL} every ${POLL_INTERVAL_MS}ms → ${OBSERVER_URL}/event`,
);
await tick();
setInterval(tick, POLL_INTERVAL_MS);
}
main().catch(e => {
console.error(`[langfuse-bridge] fatal: ${e}`);
process.exit(1);
});
+3 -366
View File
@@ -37,7 +37,7 @@ interface ObservedOp {
// Phase 24 — optional provenance so error analyzer and playbook
// builder can differentiate MCP-layer ops from scenario-sourced
// events. Scenarios set source="scenario" + staffer_id + sig_hash.
source?: "mcp" | "scenario" | "langfuse" | "overseer_correction";
source?: "mcp" | "scenario";
staffer_id?: string;
sig_hash?: string;
event_kind?: string;
@@ -47,12 +47,6 @@ interface ObservedOp {
count?: number;
rescue_attempted?: boolean;
rescue_succeeded?: boolean;
// Overseer-correction-specific (2026-04-23): lets the analyzer
// correlate corrections with the drift that prompted them and with
// subsequent outcomes that either validated or invalidated the advice.
task_class?: string;
correction?: string;
applied_at_turn?: number;
}
const recentOps: ObservedOp[] = [];
@@ -141,270 +135,6 @@ async function persistOp(op: ObservedOp) {
}
// ─── LLM Team escalation (code_review mode) ───
//
// When recent failures on a single sig_hash cross a threshold the
// local qwen2.5 analysis is probably insufficient. J's 2026-04-24
// direction: "the observer would trigger to give more context" —
// route failure clusters to LLM Team's specialized code_review mode
// (via /api/run) so richer structured signal lands in the KB for
// scrum + auditor + playbook memory to consume next pass.
//
// Non-destructive: runs in parallel to the existing qwen2.5 analysis,
// never replaces it. Writes to data/_kb/observer_escalations.jsonl
// as a dedicated audit surface.
const LLM_TEAM = process.env.LH_LLM_TEAM_URL ?? "http://localhost:5000";
const LLM_TEAM_ESCALATIONS = "/home/profit/lakehouse/data/_kb/observer_escalations.jsonl";
const ESCALATION_THRESHOLD = 3; // N+ failures on same sig_hash triggers
async function escalateFailureClusterToLLMTeam(sigHash: string, cluster: ObservedOp[]) {
// Package the failure cluster as a single context blob. Originally
// I routed this to LLM Team's `code_review` mode at /api/run, but
// that mode isn't registered in llm_team_ui.py — it returned
// "Unknown mode" on every call. Revised 2026-04-24: route directly
// to the gateway's /v1/chat with provider=ollama_cloud + qwen3-coder:480b
// (the coding specialist that's rung 2 of the scrum ladder, proven
// to produce substantive structured reviews). Fire-and-forget so
// downstream failures don't block observer's normal loop.
const context = cluster.slice(-8).map((o, i) =>
`[${i + 1}] endpoint=${o.endpoint} input=${o.input_summary} error=${o.error ?? "?"}`
).join("\n");
const prompt = `sig_hash=${sigHash} · ${cluster.length} failures on the same signature:\n\n${context}\n\nReview this failure cluster. Identify:\n1. Likely root cause (single sentence).\n2. Files most likely responsible (path hints).\n3. Concrete fix direction (under 3 sentences).\n4. Confidence: NN%\n\nBe specific, not generic.`;
try {
const resp = await fetch(`${LAKEHOUSE}/v1/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "ollama_cloud",
model: "qwen3-coder:480b",
messages: [{ role: "user", content: prompt }],
max_tokens: 800,
temperature: 0.2,
}),
signal: AbortSignal.timeout(60000),
});
if (!resp.ok) {
console.error(`[observer] escalation /v1/chat ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
return;
}
const j: any = await resp.json();
const analysis = j?.choices?.[0]?.message?.content ?? "";
// Audit row stays schema-compatible with the prior implementation —
// downstream consumers see structured fields regardless of the
// review-source change. Facts/entities stay empty (this call is
// direct-model, not extract-mode); the raw analysis carries the
// signal.
const row = {
ts: new Date().toISOString(),
source: "observer_escalation",
mode: "direct_chat_qwen3_coder_480b",
sig_hash: sigHash,
cluster_size: cluster.length,
cluster_staffer: cluster[0]?.staffer_id,
cluster_endpoint: cluster[0]?.endpoint,
prompt_tokens: j?.usage?.prompt_tokens ?? 0,
completion_tokens: j?.usage?.completion_tokens ?? 0,
analysis: analysis.slice(0, 4000),
};
const { appendFile } = await import("node:fs/promises");
await appendFile(LLM_TEAM_ESCALATIONS, JSON.stringify(row) + "\n");
console.error(
`[observer] escalated sig_hash=${sigHash.slice(0, 8)} · cluster=${cluster.length} · ${analysis.length} chars`
);
} catch (e) {
console.error(`[observer] escalation failed: ${(e as Error).message}`);
}
}
// Track which sig_hashes we've already escalated this session so we
// don't hammer LLM Team on every analyzeErrors tick when a cluster
// persists across cycles.
const escalatedSigHashes = new Set<string>();
// ─── Hand-review for scrum/agent candidate responses (2026-04-25) ───
//
// Observer is OUTSIDE the scrum loop's epistemic scope, so its verdict
// can be treated as truth about whether a candidate review is grounded.
// Two-tier evaluator:
// 1. Try cloud LLM (qwen3-coder:480b) — semantic judgment with
// response + source excerpt + grounding stats as context.
// 2. On cloud failure (throttle/timeout) → deterministic heuristic
// over grounding_pct + total_quotes. Marked source: "heuristic"
// so consumers can tell which rung produced the verdict.
// Every verdict is persisted to data/_kb/observer_reviews.jsonl.
const OBSERVER_REVIEWS = "/home/profit/lakehouse/data/_kb/observer_reviews.jsonl";
interface HandReviewInput {
file_path: string;
model: string;
response: string;
source_content: string;
grounding_stats: { total: number; grounded: number; groundedPct: number | null };
attempt: number;
}
interface HandReviewVerdict {
verdict: "accept" | "reject" | "cycle";
confidence: number;
notes: string;
source: "cloud" | "heuristic";
}
async function handReview(input: HandReviewInput): Promise<HandReviewVerdict> {
const t0 = Date.now();
let verdict: HandReviewVerdict;
try {
verdict = await cloudHandReview(input);
} catch (e) {
console.error(`[observer/review] cloud failed (${(e as Error).message}); using heuristic`);
verdict = heuristicHandReview(input);
}
// Persist regardless of source so we can later compare cloud vs
// heuristic verdicts on the same input and tune the heuristic.
const row = {
ts: new Date().toISOString(),
file_path: input.file_path,
model: input.model,
attempt: input.attempt,
response_chars: input.response.length,
grounding_stats: input.grounding_stats,
verdict: verdict.verdict,
confidence: verdict.confidence,
notes: verdict.notes,
source: verdict.source,
duration_ms: Date.now() - t0,
};
try {
const { appendFile } = await import("node:fs/promises");
await appendFile(OBSERVER_REVIEWS, JSON.stringify(row) + "\n");
} catch { /* best-effort persistence */ }
return verdict;
}
async function cloudHandReview(input: HandReviewInput): Promise<HandReviewVerdict> {
const grounded = input.grounding_stats.grounded;
const total = input.grounding_stats.total;
const pct = input.grounding_stats.groundedPct;
// Truncate to keep the prompt under typical context windows.
// 2000 + 4000 = ~6000 chars ≈ 1500 tokens, plus response context.
const responseExcerpt = input.response.slice(0, 2000);
const sourceExcerpt = input.source_content.slice(0, 4000);
const prompt = `You are a code-review quality observer. Decide whether the following automated review is grounded in the actual source — not invented, not hallucinated.
FILE: ${input.file_path}
MODEL: ${input.model}
ATTEMPT: ${input.attempt}
ANCHOR GROUNDING: ${grounded}/${total} backtick-quoted snippets matched the source verbatim${pct !== null ? ` (${pct}%)` : ""}
REVIEW (first 2000 chars):
\`\`\`
${responseExcerpt}
\`\`\`
SOURCE EXCERPT (first 4000 chars):
\`\`\`
${sourceExcerpt}
\`\`\`
Respond ONLY with a JSON object:
{
"verdict": "accept" | "reject" | "cycle",
"confidence": 0-100,
"notes": "<1-2 sentences on what makes this grounded or hallucinated>"
}
- accept: review references real symbols/lines in source; findings could be acted on.
- reject: review invents APIs, fabricates calls, contradicts source. Do NOT record.
- cycle: review is mediocre — partially grounded but wrong shape, try a stronger model.`;
// Hand-review uses paid OpenRouter so it sidesteps the Ollama Cloud
// throttle that drove every prior iter into the heuristic fallback.
// Grok 4.1 fast: $0.20 in / $0.50 out per M tokens, 2M ctx. A typical
// hand-review (~6K input + 300 output) costs ~$0.0014. Selected via
// J directive 2026-04-25 ("best model under $0.72/M").
const resp = await fetch(`${LAKEHOUSE}/v1/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: "openrouter",
model: "x-ai/grok-4.1-fast",
messages: [{ role: "user", content: prompt }],
max_tokens: 300,
temperature: 0.0,
}),
signal: AbortSignal.timeout(45000),
});
if (!resp.ok) {
throw new Error(`/v1/chat ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
}
const j: any = await resp.json();
const content = (j?.choices?.[0]?.message?.content ?? "").trim();
// Pull JSON object from the response — model may wrap it in prose.
const m = content.match(/\{[\s\S]*\}/);
if (!m) throw new Error(`no JSON object in response: ${content.slice(0, 100)}`);
const parsed = JSON.parse(m[0]);
const v = String(parsed.verdict ?? "accept").toLowerCase();
return {
verdict: (v === "reject" || v === "cycle") ? v as "reject" | "cycle" : "accept",
confidence: Number(parsed.confidence ?? 50),
notes: String(parsed.notes ?? "").slice(0, 500),
source: "cloud",
};
}
function heuristicHandReview(input: HandReviewInput): HandReviewVerdict {
// Deterministic fallback when cloud is throttled. Conservative:
// only flip to reject when the evidence is overwhelming, otherwise
// accept (fall-open principle — observer is policy, not blocker).
const total = input.grounding_stats.total;
const pct = input.grounding_stats.groundedPct;
const respLen = input.response.length;
// Too short to be a real review
if (respLen < 1500) {
return { verdict: "reject", confidence: 80, notes: `response too short (${respLen} chars)`, source: "heuristic" };
}
// Below 5 quotes — not enough signal to judge grounding; accept
if (total < 5 || pct === null) {
return { verdict: "accept", confidence: 50, notes: `insufficient quote signal (${total} quotes); accepting`, source: "heuristic" };
}
// Very heavy hallucination
if (pct < 20) {
return { verdict: "reject", confidence: 85, notes: `low grounding (${pct}% of ${total} quotes)`, source: "heuristic" };
}
// Mediocre — cycle to a stronger model
if (pct < 50) {
return { verdict: "cycle", confidence: 65, notes: `mediocre grounding (${pct}% of ${total} quotes); try stronger`, source: "heuristic" };
}
// Good enough
return { verdict: "accept", confidence: 75, notes: `grounding ${pct}% of ${total} quotes`, source: "heuristic" };
}
async function maybeEscalate(failures: ObservedOp[]) {
// Group failures by sig_hash
const bySig = new Map<string, ObservedOp[]>();
for (const f of failures) {
const k = f.sig_hash ?? "__no_sig__";
(bySig.get(k) ?? bySig.set(k, []).get(k)!).push(f);
}
for (const [sigHash, cluster] of bySig) {
if (sigHash === "__no_sig__") continue;
if (cluster.length < ESCALATION_THRESHOLD) continue;
if (escalatedSigHashes.has(sigHash)) continue;
escalatedSigHashes.add(sigHash);
// Fire-and-forget — don't block the existing analyzer loop.
escalateFailureClusterToLLMTeam(sigHash, cluster).catch(() => {});
}
}
// ─── Error analyzer loop ───
async function analyzeErrors() {
@@ -412,11 +142,6 @@ async function analyzeErrors() {
const failures = recentOps.filter(op => !op.success);
if (failures.length === 0) return;
// NEW 2026-04-24: escalate recurring sig_hash clusters to LLM Team
// code_review mode. Runs in parallel to the local qwen2.5 analysis
// below — non-blocking, richer downstream signal for scrum/auditor.
maybeEscalate(failures).catch(() => {});
const errorSummary = failures.slice(-10).map(f =>
`[${f.endpoint}] ${f.input_summary}: ${f.error}`
).join("\n");
@@ -526,28 +251,6 @@ function startHttpListener() {
.map(o => ({ ts: o.timestamp, ok: o.success, staffer: o.staffer_id, kind: o.event_kind, role: o.role })),
}));
}
// ─── Hand-review endpoint (2026-04-25) ───
// scrum/agent posts a candidate response + source content + grounding
// stats. Observer evaluates via cloud LLM (qwen3-coder:480b) with
// semantic context and returns {verdict, confidence, notes}. On
// cloud throttle, falls back to a deterministic heuristic over the
// grounding stats so the loop keeps moving with honest signal.
//
// This is the policy layer scrum was missing — pre-2026-04-25 the
// scrum_master applied a hardcoded grounding-rate threshold inline,
// which baked judgment into the wrong layer. Now scrum reports data
// (response + source + stats) and observer decides accept/reject/cycle.
if (req.method === "POST" && url.pathname === "/review") {
return req.json().then((body: any) => handReview(body))
.then((verdict) => new Response(JSON.stringify(verdict), {
headers: { "content-type": "application/json" },
}))
.catch((e: Error) =>
new Response(JSON.stringify({ verdict: "accept", notes: `observer error: ${e.message}`, source: "heuristic" }), {
status: 200, // fall-open shape — scrum keeps moving on observer failure
headers: { "content-type": "application/json" },
}));
}
if (req.method === "POST" && url.pathname === "/event") {
return req.json().then((body: any) => {
const op: ObservedOp = {
@@ -558,10 +261,7 @@ function startHttpListener() {
duration_ms: Number(body.duration_ms ?? 0),
output_summary: body.output_summary ?? (body.success ? "filled" : (body.error ?? "failed")),
error: body.error,
// Respect the client's provenance if set (langfuse bridge
// sends source:"langfuse", etc.). Default to "scenario"
// to keep legacy Phase 24 callers working.
source: body.source ?? "scenario",
source: "scenario",
staffer_id: body.staffer_id,
sig_hash: body.sig_hash,
event_kind: body.event_kind,
@@ -584,58 +284,6 @@ function startHttpListener() {
console.error(`[observer] HTTP listener bound to 0.0.0.0:${OBSERVER_PORT}`);
}
// ─── Overseer corrections tailer (2026-04-23) ───
// The gateway's /v1/respond loop writes T3 overseer corrections to
// data/_kb/overseer_corrections.jsonl. Tail it once per cycle and
// inject each new row into the same recentOps ring that analyzeErrors
// + consolidatePlaybooks read — so a correction that just fired shows
// up alongside the outcomes it was meant to repair, and the analyzer
// can flag patterns like "three corrections on staffing.fill with the
// same advice — underlying problem isn't a drift, it's a data gap".
const CORRECTIONS_PATH = process.env.OVERSEER_CORRECTIONS_PATH
?? "/home/profit/lakehouse/data/_kb/overseer_corrections.jsonl";
let correctionsCursor = 0; // byte offset
async function tailOverseerCorrections(): Promise<number> {
const f = Bun.file(CORRECTIONS_PATH);
if (!(await f.exists())) return 0;
const size = f.size;
if (size <= correctionsCursor) return 0;
// Read only the suffix since the last cursor; keeps tail work
// bounded even as the file grows.
const text = await f.slice(correctionsCursor, size).text();
correctionsCursor = size;
let forwarded = 0;
for (const line of text.split("\n")) {
if (!line.trim()) continue;
let row: any;
try { row = JSON.parse(line); } catch { continue; }
const op: ObservedOp = {
timestamp: row.created_at ?? new Date().toISOString(),
endpoint: `overseer:${row.model ?? "gpt-oss:120b"}`,
input_summary: `${row.task_class ?? "?"}: ${row.reason ?? "escalation"}`,
// Correction itself is neither success nor failure — it's a
// mitigation attempt. We mark success=true so analyzeErrors
// doesn't count it as a failure, but the preview lets the
// analyzer see what was tried.
success: true,
duration_ms: Number(row.usage?.latency_ms ?? 0),
output_summary: String(row.correction ?? "").slice(0, 200),
source: "overseer_correction",
sig_hash: row.sig_hash,
task_class: row.task_class,
correction: String(row.correction ?? ""),
applied_at_turn: Number(row.applied_at_turn ?? 0),
};
recordExternalOp(op);
forwarded++;
}
return forwarded;
}
// ─── Main loop ───
async function main() {
@@ -658,14 +306,7 @@ async function main() {
await Bun.sleep(CYCLE_SECS * 1000);
cycle++;
// Every cycle: tail the overseer corrections KB stream, then
// analyze errors. Order matters — if an overseer correction just
// landed for a sig_hash that previously failed, the analyzer
// should see both.
const newCorrections = await tailOverseerCorrections();
if (newCorrections > 0) {
console.error(`[observer] pulled ${newCorrections} new overseer correction(s) into ring`);
}
// Every cycle: analyze errors if any
await analyzeErrors();
// Every 5 cycles: consolidate playbooks
@@ -674,16 +315,12 @@ async function main() {
}
const scenarioOps = recentOps.filter(o => o.source === "scenario").length;
const langfuseOps = recentOps.filter(o => o.source === "langfuse").length;
const correctionOps = recentOps.filter(o => o.source === "overseer_correction").length;
const stats = {
cycle,
total_ops: recentOps.length,
successes: recentOps.filter(o => o.success).length,
failures: recentOps.filter(o => !o.success).length,
scenario_ops: scenarioOps,
langfuse_ops: langfuseOps,
overseer_corrections: correctionOps,
};
console.error(`[observer] cycle ${cycle}: ${JSON.stringify(stats)}`);
}
+16 -1357
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
[Unit]
Description=Lakehouse Gateway
After=network.target ollama.service
[Service]
Type=simple
WorkingDirectory=/home/profit/lakehouse
ExecStart=/home/profit/lakehouse/target/release/gateway
Restart=always
RestartSec=5
Environment=RUST_LOG=info
# Lance S3 support — connects to MinIO via the AWS SDK env vars.
# AWS_ALLOW_HTTP required for non-TLS MinIO endpoints.
Environment=AWS_ACCESS_KEY_ID=profit
Environment=AWS_SECRET_ACCESS_KEY=29IgevhKQjE2WHg088ieI1wP
Environment=AWS_ENDPOINT=http://localhost:9000
Environment=AWS_ALLOW_HTTP=true
Environment=AWS_DEFAULT_REGION=us-east-1
# Langfuse keys — shared with lakehouse-langfuse-bridge.service so the
# gateway-side emitter (`/v1/chat`, `/v1/respond`) and the observer
# ingest path see the same project. Leading `-` makes missing-file a
# warn-not-fatal so gateway still starts if Langfuse is torn down.
EnvironmentFile=-/etc/lakehouse/langfuse.env
[Install]
WantedBy=multi-user.target
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env bun
// Real-world inference pipeline for Chicago building permits.
// Uses the unified matrix retriever (chicago_permits + entity_brief +
// sec_tickers + llm_team_runs + distilled_procedural) to enrich a
// Grok 4.1 fast analysis. Observer hand-reviews each result.
//
// First true USE of the matrix architecture on real ingested data —
// not the scrum self-improvement loop, the staffing intelligence loop.
//
// Usage:
// bun run scripts/analyze_chicago_contracts.ts [N]
// N = number of permits to analyze (default 5)
const GATEWAY = process.env.LAKEHOUSE_URL ?? "http://localhost:3100";
const OBSERVER = process.env.LH_OBSERVER_URL ?? "http://localhost:3800";
const RAW_BUCKET = "raw";
const MC_ALIAS = "local";
const STAGE_DIR = "/tmp/chicago_analyze";
const OUTPUT = "/home/profit/lakehouse/data/_kb/contract_analyses.jsonl";
const CONTRACT_CORPORA = [
"chicago_permits_v1",
"entity_brief_v1",
"sec_tickers_v1",
"llm_team_runs_v1",
"llm_team_response_cache_v1",
"distilled_procedural_v20260423102847",
];
interface Permit {
permit_?: string;
permit_type?: string;
permit_status?: string;
work_description?: string;
reported_cost?: string | number;
contact_1?: any;
contact_2?: any;
contact_3_name?: string;
street_number?: string;
street_direction?: string;
street_name?: string;
suffix?: string;
issue_date?: string;
community_area?: string;
ward?: string;
[k: string]: any;
}
interface MatrixHit {
source_corpus: string;
score: number;
doc_id: string;
text: string;
}
function log(msg: string) { console.log(`[contract ${new Date().toISOString().slice(11,19)}] ${msg}`); }
async function fetchPermits(n: number): Promise<Permit[]> {
const fs = await import("node:fs/promises");
await fs.mkdir(STAGE_DIR, { recursive: true });
const local = `${STAGE_DIR}/permits.json`;
const proc = Bun.spawn(["mc", "cp", "-q", `${MC_ALIAS}/${RAW_BUCKET}/chicago/permits_2026-04-25.json`, local]);
await proc.exited;
const all: Permit[] = JSON.parse(await Bun.file(local).text());
// Pick high-cost permits with named contractors — most interesting for staffing analysis.
// Field is `contact_1_name`, not `contact_1`. reported_cost is integer-like string.
const meaningful = all.filter(p =>
p.reported_cost && Number(p.reported_cost) >= 100000 &&
(p.contact_1_name || p.contact_2_name)
);
log(`raw permits: ${all.length} · meaningful (cost >= $100k + has contractor): ${meaningful.length}`);
// Sample evenly across the meaningful set
const sampled: Permit[] = [];
const stride = Math.max(1, Math.floor(meaningful.length / n));
for (let i = 0; i < meaningful.length && sampled.length < n; i += stride) {
sampled.push(meaningful[i]);
}
return sampled;
}
function permitToText(p: Permit): string {
const addr = `${p.street_number ?? ""} ${p.street_direction ?? ""} ${p.street_name ?? ""} ${p.suffix ?? ""}`.replace(/\s+/g, " ").trim();
const c1 = (p as any).contact_1_name ?? (typeof p.contact_1 === "string" ? p.contact_1 : (p.contact_1?.name ?? ""));
const c2 = (p as any).contact_2_name ?? (typeof p.contact_2 === "string" ? p.contact_2 : (p.contact_2?.name ?? ""));
return [
`Chicago Building Permit ${p.permit_ ?? "?"}`,
`Type: ${p.permit_type ?? "?"} · Status: ${p.permit_status ?? "?"}`,
`Address: ${addr} · Community ${p.community_area ?? "?"} · Ward ${p.ward ?? "?"}`,
`Issued: ${p.issue_date ?? "?"}`,
`Reported cost: $${Number(p.reported_cost ?? 0).toLocaleString()}`,
`Primary contractor: ${c1 || "unknown"}`,
c2 ? `Secondary: ${c2}` : "",
`Owner: ${p.contact_3_name ?? "?"}`,
`Work description: ${(p.work_description ?? "").slice(0, 800)}`,
].filter(Boolean).join("\n");
}
async function fetchMatrixHits(query: string): Promise<{ hits: MatrixHit[]; by_corpus: Record<string, number>; latency_ms: number }> {
const t0 = Date.now();
const all: MatrixHit[] = [];
const byCorpus: Record<string, number> = {};
await Promise.all(CONTRACT_CORPORA.map(async (idx) => {
try {
const r = await fetch(`${GATEWAY}/vectors/search`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ index_name: idx, query, top_k: 3 }),
signal: AbortSignal.timeout(15000),
});
if (!r.ok) { byCorpus[idx] = -1; return; }
const data: any = await r.json();
const results = data.results ?? [];
byCorpus[idx] = results.length;
for (const h of results) {
all.push({
source_corpus: idx,
score: Number(h.score ?? 0),
doc_id: String(h.doc_id ?? "?"),
text: String(h.chunk_text ?? "").slice(0, 400),
});
}
} catch { byCorpus[idx] = -1; }
}));
all.sort((a, b) => b.score - a.score);
return { hits: all.slice(0, 10), by_corpus: byCorpus, latency_ms: Date.now() - t0 };
}
function buildMatrixPreamble(hits: MatrixHit[]): string {
if (hits.length === 0) return "";
const lines = [
`═══ 📖 MATRIX CONTEXT — ${hits.length} relevant hits across the knowledge base ═══`,
"Reference material from prior contractor data, SEC tickers, LLM team analyses, and distilled procedures. Use as evidence; do NOT invent.",
"",
];
for (let i = 0; i < hits.length; i++) {
const h = hits[i];
lines.push(`[${i + 1}] ${h.source_corpus} (score=${h.score.toFixed(2)}, doc=${h.doc_id}): ${h.text.replace(/\s+/g, " ").trim()}`);
}
lines.push("═══");
lines.push("");
return lines.join("\n");
}
async function chat(model: string, prompt: string): Promise<{ content: string; error?: string }> {
try {
const r = await fetch(`${GATEWAY}/v1/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
provider: "openrouter",
model,
messages: [{ role: "user", content: prompt }],
max_tokens: 1500,
temperature: 0.1,
}),
signal: AbortSignal.timeout(90000),
});
if (!r.ok) return { content: "", error: `HTTP ${r.status}: ${(await r.text()).slice(0, 200)}` };
const j: any = await r.json();
return { content: j.choices?.[0]?.message?.content ?? "" };
} catch (e: any) { return { content: "", error: e.message }; }
}
async function observerReview(input: { permit_id: string; model: string; response: string; permit_text: string }): Promise<{ verdict: string; confidence: number; notes: string; source: string }> {
try {
const r = await fetch(`${OBSERVER}/review`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
file_path: `chicago_permit/${input.permit_id}`,
model: input.model,
response: input.response,
source_content: input.permit_text,
grounding_stats: { total: 0, grounded: 0, groundedPct: null },
attempt: 1,
}),
signal: AbortSignal.timeout(90000),
});
if (!r.ok) return { verdict: "accept", confidence: 50, notes: `observer ${r.status}`, source: "fallthrough" };
return await r.json();
} catch (e: any) { return { verdict: "accept", confidence: 50, notes: `observer error: ${e.message}`, source: "fallthrough" }; }
}
async function analyzeOne(p: Permit, idx: number, total: number): Promise<any> {
const permit_id = p.permit_ ?? `unknown_${idx}`;
const t0 = Date.now();
log(`══ permit ${idx + 1}/${total} · ${permit_id} · type=${p.permit_type} · cost=$${Number(p.reported_cost ?? 0).toLocaleString()}`);
const permitText = permitToText(p);
// Build matrix query: combine type + work description + contractor name for retrieval anchoring
const c1 = (p as any).contact_1_name ?? (typeof p.contact_1 === "string" ? p.contact_1 : (p.contact_1?.name ?? ""));
const matrixQuery = `${p.permit_type ?? ""} ${(p.work_description ?? "").slice(0, 300)} ${c1}`;
const matrix = await fetchMatrixHits(matrixQuery);
const corporaSummary = Object.entries(matrix.by_corpus).map(([k, v]) => `${k.split("_v")[0]}=${v}`).join(" ");
log(` 📖 matrix: ${matrix.hits.length} hits in ${matrix.latency_ms}ms · ${corporaSummary}`);
const preamble = buildMatrixPreamble(matrix.hits);
const task = `${preamble}You are a staffing-intelligence analyst reviewing a real Chicago building permit. Using the MATRIX CONTEXT above as evidence, produce a structured analysis:
PERMIT:
${permitText}
Produce a markdown analysis with:
1. **Permit summary** — 2 sentences on what this is
2. **Contractor signal** — what we know about the named contractor(s) from matrix context (cite [N] hits). If unknown, say so.
3. **Staffing fit** — what trades/headcount/skills this permit implies
4. **Risk flags** — anything in matrix context that suggests caution (debarment, prior incidents, low-quality history). If none, say so.
5. **Opportunity score** — 0-100 with one-sentence rationale
Cite matrix hits as [N] inline. If matrix has no relevant hits, say "no matrix evidence" — do NOT invent contractor history.`;
const resp = await chat("x-ai/grok-4.1-fast", task);
if (resp.error) {
log(` ✗ chat error: ${resp.error.slice(0, 100)}`);
return { permit_id, ok: false, error: resp.error, ts: new Date().toISOString() };
}
log(` ✓ analysis ${resp.content.length} chars`);
const verdict = await observerReview({ permit_id, model: "openrouter/x-ai/grok-4.1-fast", response: resp.content, permit_text: permitText });
log(` observer: ${verdict.verdict} (conf=${verdict.confidence}, src=${verdict.source})`);
return {
permit_id, ok: true,
permit_type: p.permit_type, cost: Number(p.reported_cost ?? 0),
contractor: c1, matrix_hits: matrix.hits.length, matrix_corpora: matrix.by_corpus, matrix_ms: matrix.latency_ms,
analysis: resp.content,
observer_verdict: verdict.verdict, observer_conf: verdict.confidence, observer_notes: verdict.notes, observer_src: verdict.source,
duration_ms: Date.now() - t0, ts: new Date().toISOString(),
};
}
async function main() {
const n = Number(process.argv[2] ?? 5);
log(`fetching ${n} permits from raw bucket...`);
const permits = await fetchPermits(n);
log(`analyzing ${permits.length} permits sequentially...`);
const fs = await import("node:fs/promises");
const { appendFile } = fs;
const results: any[] = [];
for (let i = 0; i < permits.length; i++) {
const r = await analyzeOne(permits[i], i, permits.length);
results.push(r);
await appendFile(OUTPUT, JSON.stringify(r) + "\n");
}
log(`\n══ SUMMARY ══`);
const ok = results.filter(r => r.ok).length;
const accepted = results.filter(r => r.observer_verdict === "accept").length;
const cycled = results.filter(r => r.observer_verdict === "cycle").length;
const rejected = results.filter(r => r.observer_verdict === "reject").length;
const avgHits = results.reduce((a, r) => a + (r.matrix_hits ?? 0), 0) / Math.max(1, results.length);
log(` permits analyzed: ${ok}/${results.length}`);
log(` observer: accept=${accepted} cycle=${cycled} reject=${rejected}`);
log(` avg matrix hits per permit: ${avgHits.toFixed(1)}`);
log(` output: ${OUTPUT}`);
}
main().catch(e => { console.error(`FATAL: ${e.message}`); process.exit(1); });
-95
View File
@@ -1,95 +0,0 @@
#!/bin/bash
# One-shot dump of all testing data into the `raw` MinIO bucket.
# Persistent test corpus so we don't re-extract every run.
#
# Layout:
# raw/
# staffing/ — workers_500k.parquet, resumes.parquet
# entities/ — entities.jsonl, sec_company_tickers.json
# llm_team/ — *.jsonl extracts from knowledge_base PG tables
# chicago/ — permits_<date>.json (last 30 days)
# MANIFEST.json — documents what's here + when
set -euo pipefail
REPO=/home/profit/lakehouse
BUCKET=raw
ALIAS=local
STAGE=$(mktemp -d /tmp/raw_dump.XXXXX)
trap 'rm -rf "$STAGE"' EXIT
DATE=$(date -u +%Y-%m-%d)
log() { echo "[dump $(date -u +%H:%M:%S)] $*"; }
log "creating bucket ${ALIAS}/${BUCKET} (idempotent)"
mc mb --ignore-existing ${ALIAS}/${BUCKET}
# ─── 1. STAFFING ───
log "staffing/ — workers_500k.parquet (323 MB) + resumes.parquet"
mc cp -q ${REPO}/data/datasets/workers_500k.parquet ${ALIAS}/${BUCKET}/staffing/workers_500k.parquet
mc cp -q ${REPO}/data/datasets/resumes.parquet ${ALIAS}/${BUCKET}/staffing/resumes.parquet
# ─── 2. ENTITIES + SEC + GEO ───
log "entities/ — contractor entities cache + SEC tickers + svep + tif districts"
mc cp -q ${REPO}/data/_entity_cache/entities.jsonl ${ALIAS}/${BUCKET}/entities/entities.jsonl
mc cp -q ${REPO}/data/_entity_cache/sec_company_tickers.json ${ALIAS}/${BUCKET}/sec/company_tickers.json
mc cp -q ${REPO}/data/_entity_cache/svep_log.json ${ALIAS}/${BUCKET}/entities/svep_log.json
mc cp -q ${REPO}/data/_entity_cache/tif_districts.geojson ${ALIAS}/${BUCKET}/chicago/tif_districts.geojson
# ─── 3. LLM TEAM HISTORY (Postgres → JSONL → S3) ───
log "llm_team/ — extracting from knowledge_base PG tables"
LLM_TABLES=(team_runs pipeline_runs lab_experiments lab_trials meta_pipelines meta_runs conversations response_cache memory_entries adaptive_runs)
for tbl in "${LLM_TABLES[@]}"; do
out=${STAGE}/${tbl}.jsonl
rows=$(sudo -u postgres psql -d knowledge_base -At -c "SELECT COUNT(*) FROM ${tbl};" 2>/dev/null || echo 0)
if [ "$rows" -eq 0 ]; then
log " · ${tbl}: 0 rows, skipping"
continue
fi
sudo -u postgres psql -d knowledge_base -At -c "COPY (SELECT row_to_json(t) FROM ${tbl} t) TO STDOUT;" > "$out" 2>/dev/null
size=$(du -h "$out" | awk '{print $1}')
log " · ${tbl}: ${rows} rows (${size})"
mc cp -q "$out" ${ALIAS}/${BUCKET}/llm_team/${tbl}.jsonl
done
# ─── 4. CHICAGO PERMITS (last 30 days, paginated) ───
log "chicago/ — pulling last 30 days of permits from data.cityofchicago.org"
since=$(date -u -d '30 days ago' +%Y-%m-%d)
out=${STAGE}/permits_${DATE}.json
url="https://data.cityofchicago.org/resource/ydr8-5enu.json?\$where=issue_date%3E='${since}'&\$limit=10000&\$order=issue_date%20DESC"
if curl -sf --max-time 60 "$url" -o "$out"; then
count=$(python3 -c "import json; print(len(json.load(open('${out}'))))")
size=$(du -h "$out" | awk '{print $1}')
log " · permits since ${since}: ${count} records (${size})"
mc cp -q "$out" ${ALIAS}/${BUCKET}/chicago/permits_${DATE}.json
else
log " · WARN: chicago permits fetch failed; skipping"
fi
# ─── 5. MANIFEST ───
log "writing MANIFEST.json"
manifest=${STAGE}/MANIFEST.json
python3 - <<PY
import json, subprocess, datetime
out = subprocess.check_output(['mc','ls','-r','--json','${ALIAS}/${BUCKET}'], text=True)
items = []
for line in out.strip().split('\n'):
if not line: continue
o = json.loads(line)
items.append({'key': o.get('key',''), 'size': o.get('size',0)})
total_size = sum(i['size'] for i in items)
manifest = {
'bucket': '${BUCKET}',
'created_at': datetime.datetime.utcnow().isoformat() + 'Z',
'total_objects': len(items),
'total_size_bytes': total_size,
'total_size_human': f'{total_size / (1024*1024):.1f} MB',
'items': items,
}
with open('${manifest}','w') as f:
json.dump(manifest, f, indent=2)
PY
mc cp -q "$manifest" ${ALIAS}/${BUCKET}/MANIFEST.json
log "DONE. Bucket contents:"
mc ls -r ${ALIAS}/${BUCKET} | head -30
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env bun
// Seal the iter-4 successful agent trace as a playbook in the matrix,
// then verify the matrix can retrieve it via a similarity query.
//
// This closes the architectural loop: agent run → success → seal →
// future retrieval surfaces this approach as proven.
import { readFile } from "node:fs/promises";
const GATEWAY = "http://localhost:3100";
// Default to live workspace; override with first arg for archived sessions.
const SESSION_DIR = process.argv[2] ?? "/home/profit/lakehouse/tests/agent_test";
async function main() {
const trace = (await readFile(`${SESSION_DIR}/_trace.jsonl`, "utf8"))
.split("\n").filter(l => l.trim()).map(l => JSON.parse(l));
const finalMd = await readFile(`${SESSION_DIR}/_final.md`, "utf8");
// Extract tool sequence from trace
const toolCalls = trace.filter(t => t.kind === "tool_call");
const toolSeq = toolCalls.map(t => t.tool).join(" → ");
const totalSteps = toolCalls.length;
const totalLatency = trace.filter(t => t.latency_ms).reduce((a, t) => a + (t.latency_ms ?? 0), 0);
console.log(`iter-4 trace: ${trace.length} events, ${totalSteps} tool calls, ${(totalLatency/1000).toFixed(1)}s total`);
console.log(`tool sequence: ${toolSeq}`);
console.log(`final output: ${finalMd.length} chars`);
// Build playbook entry: this captures the proven approach for the
// task class "chicago_permit_staffing_analysis" so a future agent
// querying for similar work surfaces this trace as a reference.
const operation = `Chicago permit staffing analysis — qwen3.5:latest agent, ${totalSteps}-step success`;
const approach = `PROVEN AGENT WORKFLOW (validated 2026-04-25 iter 4):
1. PLAN FIRST via note() — explicit step list before any execution
2. list_permits(min_cost=N) — get high-cost candidates
3. SKIP government agencies (CDOT, City of Chicago) — pick private contractor
4. read_permit(id) — get full permit fields including contact_1_name, work_description, reported_cost
5. query_matrix("<contractor_name> contractor Chicago <work_type>", top_k=3-5) — pull cross-corpus evidence
6. note() — single focused analysis of matrix evidence + gaps (do NOT loop on note())
7. done(summary=<5-section markdown>) — Permit Summary, Contractor Profile, Staffing Implications, Risk Signals, Recommendation
KEY LESSONS:
- llm_team_runs_v1 + llm_team_response_cache_v1 are noise corpora — exclude
- Useful corpora: chicago_permits_v1, entity_brief_v1, sec_tickers_v1, distilled_procedural_v20260423102847
- Matrix often returns "no specific evidence" for private contractors — that's OK, acknowledge gap honestly, do NOT invent history
- Recommendation should reflect actual evidence: "Investigate-Further" when matrix is empty, not generic "Pursue"
- Total wall ≈30s for 6 tool calls`;
const context = `PRD: tests/agent_test/PRD.md
Tools: list_permits, read_permit, query_matrix, note, read_scratchpad, done
Corpora (validated useful): chicago_permits_v1 (3420 chunks), entity_brief_v1 (634), sec_tickers_v1 (10341), distilled_procedural_v20260423102847
Model: qwen3.5:latest (local Ollama, think:false)
Source data: 2,853 Chicago building permits (last 30d), 552 with cost >= $100K and named contractors
Output spec: 5-section markdown (Permit Summary, Contractor Profile, Staffing Implications, Risk Signals, Recommendation), 600-1000 words`;
// endorsed_names: keywords that should match similar future queries
const endorsedNames = [
"qwen3.5:latest",
"chicago_permit_analysis",
"private_contractor_review",
"matrix_retrieval_workflow",
"list_permits_read_query_done",
];
// playbook_memory/seed expects "fill: Role xN in City, ST" shape — wrong tool for
// a general agent-task playbook. Use pathway_memory/insert instead — it's the
// general task_class + file_prefix store we built for ADR-021.
console.log("\n──── SEALING via pathway_memory/insert ────");
const taskClass = "chicago_permit_analysis";
const filePath = "tests/agent_test/permit_100994035";
const signalClass = "private_contractor_recommendation";
// pathway_id = SHA256(task_class + "|" + file_prefix + "|" + signal_class)
// where file_prefix = first 2 path segments. Matches gateway's hot-swap logic.
const filePrefix = filePath.split("/").slice(0, 2).join("/");
const hasher = new Bun.CryptoHasher("sha256");
hasher.update(`${taskClass}|${filePrefix}|${signalClass}`);
const pathwayId = hasher.digest("hex");
console.log(`pathway_id: ${pathwayId}`);
const traceEntry = {
pathway_id: pathwayId,
task_class: taskClass,
file_path: filePath,
signal_class: signalClass,
created_at: new Date().toISOString(),
ladder_attempts: toolCalls.map((t, i) => ({
rung: i + 1,
model: t.tool === "done" ? "qwen3.5:latest+done" : `qwen3.5:latest+${t.tool}`,
latency_ms: t.latency_ms ?? 0,
accepted: t.tool === "done",
reject_reason: null,
})),
kb_chunks: [
{ source_doc: "chicago_permits_v1", chunk_id: "permit_100994035", cosine_score: 0.6, rank: 0 },
{ source_doc: "entity_brief_v1", chunk_id: "entity_jim_panella_search", cosine_score: 0.58, rank: 1 },
{ source_doc: "sec_tickers_v1", chunk_id: "sec_no_match", cosine_score: 0.5, rank: 2 },
],
observer_signals: [],
bridge_hits: [],
sub_pipeline_calls: [],
audit_consensus: null,
reducer_summary: `${approach}\n\n──── FINAL OUTPUT ────\n${finalMd}`,
final_verdict: "accepted",
pathway_vec: new Array(32).fill(0), // gateway computes/replaces if it does
replay_count: 0,
replays_succeeded: 0,
semantic_flags: [],
type_hints_used: [],
bug_fingerprints: [],
retired: false,
};
// Use Mem0-style upsert (J 2026-04-25). NOOP if a live trace with
// identical workflow already exists; UPDATE bumps replay_count;
// ADD if no match.
const seal = await fetch(`${GATEWAY}/vectors/pathway/upsert`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(traceEntry),
signal: AbortSignal.timeout(30000),
});
if (!seal.ok) {
console.error(`✗ seal failed: ${seal.status} — ${(await seal.text()).slice(0, 300)}`);
process.exit(1);
}
const sealResult = await seal.json();
console.log(`✓ sealed via pathway/upsert: ${JSON.stringify(sealResult).slice(0, 300)}`);
// sealResult.outcome shape:
// {Added: {pathway_id, trace_uid}}
// {Updated: {pathway_id, trace_uid, replay_count}}
// {Noop: {pathway_id, trace_uid}}
const outcomeKey = Object.keys(sealResult.outcome ?? {})[0];
console.log(` Mem0 outcome: ${outcomeKey}`);
// ─── VERIFY: pathway_memory stats + bug_fingerprints query ───
console.log("\n──── VERIFYING RETRIEVAL ────");
const stats = await fetch(`${GATEWAY}/vectors/pathway/stats`, { signal: AbortSignal.timeout(10000) });
if (stats.ok) {
const s: any = await stats.json();
console.log(`pathway_memory stats: total=${s.total_pathways} retired=${s.retired} reuse_rate=${s.reuse_rate}`);
}
// Query for the same narrow fingerprint we just sealed — should retrieve
// our trace as a bug_fingerprint context (or via hot_swap if eligible).
const fpQuery = await fetch(`${GATEWAY}/vectors/pathway/bug_fingerprints`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
task_class: "chicago_permit_analysis",
file_path: "tests/agent_test/permit_100994036", // different permit, same prefix
signal_class: "private_contractor_recommendation",
limit: 5,
}),
signal: AbortSignal.timeout(10000),
});
if (fpQuery.ok) {
const result: any = await fpQuery.json();
const fps = result.fingerprints ?? result;
console.log(`bug_fingerprints retrieval (sister permit, same prefix): ${JSON.stringify(fps).slice(0, 400)}`);
}
// Confirm the trace landed in state.json
const stateProbe = await Bun.file("/home/profit/lakehouse/data/_pathway_memory/state.json");
if (await stateProbe.exists()) {
const state: any = JSON.parse(await stateProbe.text());
let found = false;
for (const traces of Object.values(state.pathways ?? {}) as any[][]) {
for (const t of traces) {
if (t.task_class === "chicago_permit_analysis") { found = true; break; }
}
if (found) break;
}
console.log(`state.json contains chicago_permit_analysis trace: ${found}`);
}
}
main().catch(e => { console.error(`FATAL: ${e.message}`); process.exit(1); });
-259
View File
@@ -1,259 +0,0 @@
#!/usr/bin/env bun
// Vectorize each raw-bucket corpus into a queryable matrix index.
// Reads from local raw/ dump (bun fetch from MinIO), shapes into
// {id, text} docs, POSTs to gateway /vectors/index, polls job to done.
//
// Targets one index per source with stable names so MATRIX_CORPORA_FOR_TASK
// can reference them. Idempotent: re-running rebuilds with a fresh _v2.
//
// Usage:
// bun run scripts/vectorize_raw_corpus.ts [source...]
// Default: runs all sources in order. Sources: chicago, entities, sec, llm_team_runs, llm_team_response
const GATEWAY = process.env.LAKEHOUSE_URL ?? "http://localhost:3100";
const RAW_BUCKET = "raw";
const MC_ALIAS = "local";
const STAGE_DIR = "/tmp/vectorize_raw";
interface Doc { id: string; text: string }
interface SourceSpec {
name: string; // CLI flag
index_name: string; // /vectors/index target
s3_key: string; // path under raw/
source_label: string; // gateway "source" field
chunk_size?: number;
overlap?: number;
extractor: (raw: string) => Doc[];
}
// Spawn mc to copy from S3 → local stage so we can read it
async function fetchFromRaw(key: string): Promise<string> {
const fs = await import("node:fs/promises");
await fs.mkdir(STAGE_DIR, { recursive: true });
const local = `${STAGE_DIR}/${key.replace(/\//g, "_")}`;
const proc = Bun.spawn(["mc", "cp", "-q", `${MC_ALIAS}/${RAW_BUCKET}/${key}`, local]);
await proc.exited;
if (proc.exitCode !== 0) throw new Error(`mc cp failed for ${key}`);
return local;
}
async function readJsonl(path: string): Promise<any[]> {
const text = await Bun.file(path).text();
return text.split("\n").filter(l => l.trim()).map(l => JSON.parse(l));
}
function truncate(s: string, n = 4000): string {
return s == null ? "" : (s.length > n ? s.slice(0, n) : s);
}
// Sanitize text before posting as JSON. Strips control chars and
// drops incomplete \uXXXX escape sequences which break Rust's
// serde JSON parser at the gateway. Llm_team response cache had
// rows with truncated \u escapes that 400'd the whole batch.
function sanitize(s: string): string {
if (!s) return "";
return s
.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, "") // strip control chars
.replace(/\\/g, "") // strip ALL backslashes — kills any malformed \uXXXX in source data
.replace(/[\uD800-\uDFFF]/g, ""); // strip UTF-16 surrogates (lone ones from emoji split by truncate)
}
// ─── EXTRACTORS — one per source ───
// Each shapes raw rows into {id, text} for the gateway's chunker.
function extractChicagoPermits(raw: string): Doc[] {
const arr = JSON.parse(raw);
return arr.map((p: any, i: number) => {
const text = [
`Permit ${p.permit_ ?? p.permit_number ?? `unknown_${i}`}`,
`Type: ${p.permit_type ?? "?"} Status: ${p.permit_status ?? "?"}`,
`Address: ${p.street_number ?? ""} ${p.street_direction ?? ""} ${p.street_name ?? ""} ${p.suffix ?? ""}`.trim(),
`Issued: ${p.issue_date ?? "?"} Applied: ${p.application_start_date ?? "?"}`,
`Work: ${truncate(p.work_description ?? "", 800)}`,
`Estimated cost: ${p.reported_cost ?? p.estimated_cost ?? "?"}`,
`Contractors: ${p.contact_1 ?? ""} | ${p.contact_2 ?? ""}`,
`Owner: ${p.contact_3_name ?? ""} (${p.contact_3_type ?? ""})`,
`Subtypes: ${p.subtotal_paid ?? ""} community area=${p.community_area ?? ""} ward=${p.ward ?? ""}`,
].filter(Boolean).join("\n");
return { id: `permit_${p.permit_ ?? p.id ?? i}`, text };
});
}
function extractEntities(raw: string): Doc[] {
return raw.split("\n").filter(l => l.trim()).map((line, i) => {
try {
const e = JSON.parse(line);
const name = e.normalized_name ?? e.name ?? e.display_name ?? `entity_${i}`;
const text = [
`Entity: ${name}`,
`Display: ${e.display_name ?? name}`,
e.ticker ? `Ticker: ${e.ticker}` : "",
e.cik ? `CIK: ${e.cik}` : "",
e.aliases ? `Aliases: ${(e.aliases ?? []).join(", ")}` : "",
e.last_seen ? `Last seen: ${e.last_seen}` : "",
e.notes ? `Notes: ${truncate(JSON.stringify(e.notes), 600)}` : "",
`Raw: ${truncate(JSON.stringify(e), 1500)}`,
].filter(Boolean).join("\n");
return { id: `entity_${name}_${i}`, text };
} catch {
return { id: `entity_${i}`, text: line.slice(0, 1000) };
}
});
}
function extractSecTickers(raw: string): Doc[] {
// SEC tickers JSON: {"_fetched_at": ..., "rows": {"0": {cik_str, ticker, title}, ...}}
const obj = JSON.parse(raw);
// The actual rows are under .rows; fall back to top-level if no wrapper.
const rows = obj.rows ?? obj;
return Object.values(rows)
.filter((r: any) => r && typeof r === "object" && r.ticker)
.map((row: any, i: number) => ({
id: `sec_${row.ticker ?? i}`,
text: `Ticker: ${row.ticker}\nCompany: ${row.title ?? "?"}\nCIK: ${row.cik_str ?? "?"}`,
}));
}
function extractLlmTeamRuns(raw: string): Doc[] {
return raw.split("\n").filter(l => l.trim()).map((line, i) => {
try {
const r = JSON.parse(line);
const text = [
`Team run ${r.id ?? i}`,
`Mode: ${r.mode ?? "?"} Created: ${r.created_at ?? "?"}`,
r.prompt ? `Prompt: ${truncate(r.prompt, 1200)}` : "",
r.input ? `Input: ${truncate(typeof r.input === "string" ? r.input : JSON.stringify(r.input), 1200)}` : "",
r.output ? `Output: ${truncate(typeof r.output === "string" ? r.output : JSON.stringify(r.output), 2000)}` : "",
r.result ? `Result: ${truncate(typeof r.result === "string" ? r.result : JSON.stringify(r.result), 2000)}` : "",
r.metadata ? `Meta: ${truncate(JSON.stringify(r.metadata), 600)}` : "",
].filter(Boolean).join("\n");
return { id: `team_run_${r.id ?? i}`, text };
} catch {
return { id: `team_run_${i}`, text: line.slice(0, 2000) };
}
});
}
function extractLlmTeamResponseCache(raw: string): Doc[] {
return raw.split("\n").filter(l => l.trim()).map((line, i) => {
try {
const r = JSON.parse(line);
const text = [
`Cached response ${r.cache_key ?? r.id ?? i}`,
`Created: ${r.created_at ?? "?"}`,
r.prompt ? `Prompt: ${sanitize(truncate(r.prompt, 1500))}` : "",
r.response ? `Response: ${sanitize(truncate(r.response, 2500))}` : "",
r.model ? `Model: ${r.model}` : "",
].filter(Boolean).join("\n");
return { id: `resp_${r.cache_key ?? r.id ?? i}`, text };
} catch {
return { id: `resp_${i}`, text: sanitize(line.slice(0, 2000)) };
}
});
}
const SOURCES: SourceSpec[] = [
{ name: "chicago", index_name: "chicago_permits_v1", s3_key: "chicago/permits_2026-04-25.json",
source_label: "chicago_permits", chunk_size: 600, overlap: 80, extractor: extractChicagoPermits },
{ name: "entities", index_name: "entity_brief_v1", s3_key: "entities/entities.jsonl",
source_label: "entity_brief", chunk_size: 500, overlap: 60, extractor: extractEntities },
{ name: "sec", index_name: "sec_tickers_v1", s3_key: "sec/company_tickers.json",
source_label: "sec_tickers", chunk_size: 200, overlap: 20, extractor: extractSecTickers },
{ name: "llm_team_runs", index_name: "llm_team_runs_v1", s3_key: "llm_team/team_runs.jsonl",
source_label: "llm_team_runs", chunk_size: 800, overlap: 100, extractor: extractLlmTeamRuns },
{ name: "llm_team_response", index_name: "llm_team_response_cache_v1", s3_key: "llm_team/response_cache.jsonl",
source_label: "llm_team_response_cache", chunk_size: 800, overlap: 100, extractor: extractLlmTeamResponseCache },
];
async function vectorizeOne(spec: SourceSpec): Promise<{ ok: boolean; chunks: number; job_id?: string; err?: string }> {
const t0 = Date.now();
console.log(`\n━━━ ${spec.name} → ${spec.index_name} ━━━`);
console.log(`fetching s3://${RAW_BUCKET}/${spec.s3_key}`);
let local: string;
try { local = await fetchFromRaw(spec.s3_key); }
catch (e: any) { return { ok: false, chunks: 0, err: `fetch: ${e.message}` }; }
console.log(`reading + extracting...`);
const raw = await Bun.file(local).text();
const docs = spec.extractor(raw);
if (docs.length === 0) return { ok: false, chunks: 0, err: "0 docs after extraction" };
console.log(` ${docs.length} docs (avg ${Math.round(docs.reduce((a, d) => a + d.text.length, 0) / docs.length)} chars)`);
console.log(`POST /vectors/index ${spec.index_name} ...`);
const resp = await fetch(`${GATEWAY}/vectors/index`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
index_name: spec.index_name,
source: spec.source_label,
documents: docs,
chunk_size: spec.chunk_size,
overlap: spec.overlap,
}),
signal: AbortSignal.timeout(300000),
});
if (!resp.ok) {
const body = await resp.text();
return { ok: false, chunks: 0, err: `HTTP ${resp.status}: ${body.slice(0, 300)}` };
}
const j: any = await resp.json();
const ms = Date.now() - t0;
console.log(` ✓ submitted: job=${j.job_id} chunks=${j.chunks} (extract+submit ${(ms/1000).toFixed(1)}s)`);
return { ok: true, chunks: j.chunks, job_id: j.job_id };
}
async function pollJob(jobId: string): Promise<{ status: string; processed: number; total: number }> {
const r = await fetch(`${GATEWAY}/vectors/jobs/${jobId}`, { signal: AbortSignal.timeout(5000) });
if (!r.ok) return { status: "unknown", processed: 0, total: 0 };
const j: any = await r.json();
return { status: j.status ?? "?", processed: j.processed ?? 0, total: j.total ?? 0 };
}
async function waitForJob(jobId: string, label: string, maxSec = 600): Promise<void> {
const t0 = Date.now();
let lastLog = 0;
while ((Date.now() - t0) / 1000 < maxSec) {
const s = await pollJob(jobId);
if (s.status === "complete" || s.status === "completed" || s.status === "done") {
console.log(` ✓ ${label} job ${jobId.slice(0,8)} complete (${s.processed}/${s.total} in ${((Date.now()-t0)/1000).toFixed(0)}s)`);
return;
}
if (s.status === "failed" || s.status === "error") {
console.log(` ✗ ${label} job ${jobId.slice(0,8)} failed at ${s.processed}/${s.total}`);
return;
}
if (Date.now() - lastLog > 15000) {
console.log(` · ${label} progress ${s.processed}/${s.total} (${s.status})`);
lastLog = Date.now();
}
await new Promise(r => setTimeout(r, 3000));
}
console.log(` ⚠ ${label} job ${jobId.slice(0,8)} still running after ${maxSec}s — leaving in background`);
}
async function main() {
const args = process.argv.slice(2);
const targets = args.length > 0 ? SOURCES.filter(s => args.includes(s.name)) : SOURCES;
console.log(`Vectorizing ${targets.length} source(s): ${targets.map(t => t.name).join(", ")}`);
const results: Array<{ name: string; result: any }> = [];
for (const spec of targets) {
try {
const r = await vectorizeOne(spec);
if (r.ok && r.job_id) await waitForJob(r.job_id, spec.name);
results.push({ name: spec.name, result: r });
} catch (e: any) {
console.error(`! ${spec.name}: ${e.message}`);
results.push({ name: spec.name, result: { ok: false, err: e.message } });
}
}
console.log(`\n━━━ SUMMARY ━━━`);
for (const { name, result } of results) {
console.log(` ${result.ok ? "✓" : "✗"} ${name.padEnd(20)} chunks=${result.chunks ?? 0} ${result.err ? `err=${result.err}` : ""}`);
}
}
main().catch(e => { console.error(`FATAL: ${e.message}`); process.exit(1); });
-353
View File
@@ -1,353 +0,0 @@
#!/usr/bin/env bun
// Agent harness — runs local qwen3.5:latest as an autonomous agent
// against PRD.md. Exposes a tool-call loop. Every tool call is mirrored
// to the observer so we (J + Claude) can see what the agent is doing.
//
// Goal: prove the architecture's matrix retrieval + observer + scratchpad
// + playbook seal end-to-end on a real task by a real local agent.
//
// Iter 1: just run it. Watch where it gets stuck.
// Iter N: tune helpers based on what we observed.
import { appendFile, readFile } from "node:fs/promises";
import { existsSync, mkdirSync } from "node:fs";
const GATEWAY = "http://localhost:3100";
const SIDECAR = "http://localhost:3200";
const OBSERVER = "http://localhost:3800";
const PRD_PATH = "/home/profit/lakehouse/tests/agent_test/PRD.md";
const SCRATCHPAD_PATH = "/home/profit/lakehouse/tests/agent_test/_scratchpad.txt";
const TRACE_PATH = "/home/profit/lakehouse/tests/agent_test/_trace.jsonl";
const FINAL_PATH = "/home/profit/lakehouse/tests/agent_test/_final.md";
const PERMITS_RAW = "/tmp/vectorize_raw/chicago_permits_2026-04-25.json";
const AGENT_MODEL = process.env.AGENT_MODEL ?? "qwen3.5:latest";
const MAX_STEPS = Number(process.env.AGENT_MAX_STEPS ?? 15);
const SESSION_ID = `agent_${Date.now().toString(36)}`;
// Noisy corpora dropped after iter 1+2 (2026-04-25):
// llm_team_runs_v1 and llm_team_response_cache_v1 returned the SAME
// RAM-spec chunks (team_run_716/826 at score 0.59) regardless of query.
// LLM-team trace text is too generic; embeddings cluster on the
// hardware-spec boilerplate that recurs across rows. Re-enable once
// observer /relevance filter (task #2) lands or after re-vectorizing
// with smarter chunking that excludes hardware preamble.
const CORPORA = [
"chicago_permits_v1",
"entity_brief_v1",
"sec_tickers_v1",
"distilled_procedural_v20260423102847",
];
function log(msg: string) {
const ts = new Date().toISOString().slice(11, 19);
console.log(`[harness ${ts}] ${msg}`);
}
async function emitObserverEvent(payload: object) {
try {
await fetch(`${OBSERVER}/event`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ source: "agent_test", session_id: SESSION_ID, ...payload, ts: new Date().toISOString() }),
signal: AbortSignal.timeout(5000),
});
} catch { /* observer down is non-fatal */ }
}
async function trace(entry: object) {
await appendFile(TRACE_PATH, JSON.stringify({ ts: new Date().toISOString(), session_id: SESSION_ID, ...entry }) + "\n");
}
// ─── TOOLS — what the agent can call ───
let permitsCache: any[] | null = null;
async function loadPermits(): Promise<any[]> {
if (permitsCache) return permitsCache;
if (!existsSync(PERMITS_RAW)) {
// Fetch from raw bucket via mc
const proc = Bun.spawn(["mc", "cp", "-q", "local/raw/chicago/permits_2026-04-25.json", PERMITS_RAW]);
await proc.exited;
}
permitsCache = JSON.parse(await readFile(PERMITS_RAW, "utf8"));
return permitsCache!;
}
async function tool_list_permits(args: { min_cost?: number; permit_type?: string }): Promise<string> {
const all = await loadPermits();
let filtered = all.filter(p => p.contact_1_name || p.contact_2_name);
if (args.min_cost) filtered = filtered.filter(p => Number(p.reported_cost ?? 0) >= args.min_cost!);
if (args.permit_type) filtered = filtered.filter(p => (p.permit_type ?? "").toLowerCase().includes(args.permit_type!.toLowerCase()));
filtered.sort((a, b) => Number(b.reported_cost ?? 0) - Number(a.reported_cost ?? 0));
const out = filtered.slice(0, 5).map(p =>
`- permit_id=${p.permit_} type=${p.permit_type} cost=$${Number(p.reported_cost ?? 0).toLocaleString()} contractor=${p.contact_1_name ?? "?"}`
).join("\n");
return `Top ${Math.min(5, filtered.length)} of ${filtered.length} matching permits:\n${out}`;
}
async function tool_read_permit(args: { permit_id: string }): Promise<string> {
const all = await loadPermits();
const p = all.find(x => x.permit_ === args.permit_id);
if (!p) return `permit ${args.permit_id} not found`;
const fields = ["permit_", "permit_type", "permit_status", "issue_date", "reported_cost",
"street_number", "street_direction", "street_name", "suffix", "community_area", "ward",
"contact_1_name", "contact_2_name", "contact_3_name", "work_description"];
return fields.map(f => `${f}: ${p[f] ?? ""}`).join("\n");
}
async function tool_query_matrix(args: { query: string; top_k?: number }): Promise<string> {
const k = args.top_k ?? 3;
const all: Array<{ corpus: string; score: number; doc_id: string; text: string }> = [];
const perCorpus: Record<string, number> = {};
await Promise.all(CORPORA.map(async (corpus) => {
try {
const r = await fetch(`${GATEWAY}/vectors/search`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ index_name: corpus, query: args.query, top_k: k }),
signal: AbortSignal.timeout(10000),
});
if (!r.ok) { perCorpus[corpus] = -1; return; }
const data: any = await r.json();
const results = data.results ?? [];
perCorpus[corpus] = results.length;
for (const h of results) {
all.push({ corpus, score: Number(h.score ?? 0), doc_id: String(h.doc_id ?? "?"), text: String(h.chunk_text ?? "").slice(0, 300) });
}
} catch { perCorpus[corpus] = -1; }
}));
all.sort((a, b) => b.score - a.score);
const top = all.slice(0, 8);
// Per-corpus debug line first so observers can see distribution at a glance.
const dist = Object.entries(perCorpus).map(([k, v]) => `${k.split("_v")[0]}=${v}`).join(" ");
if (top.length === 0) return `no matrix evidence for: ${args.query}\n(per-corpus: ${dist})`;
return `(per-corpus: ${dist})\n` + top.map((h, i) => `[${i + 1}] ${h.corpus} score=${h.score.toFixed(2)} doc=${h.doc_id}\n ${h.text.replace(/\s+/g, " ").trim()}`).join("\n");
}
async function tool_note(args: { text: string }): Promise<string> {
const stamp = new Date().toISOString().slice(11, 19);
await appendFile(SCRATCHPAD_PATH, `[${stamp}] ${args.text}\n`);
return `noted (${args.text.length} chars)`;
}
async function tool_read_scratchpad(): Promise<string> {
if (!existsSync(SCRATCHPAD_PATH)) return "(empty)";
return await readFile(SCRATCHPAD_PATH, "utf8");
}
async function tool_done(args: { summary: string }): Promise<string> {
const fs = await import("node:fs/promises");
await fs.writeFile(FINAL_PATH, args.summary);
return `done; final saved to ${FINAL_PATH} (${args.summary.length} chars)`;
}
const TOOLS: Record<string, (args: any) => Promise<string>> = {
list_permits: tool_list_permits,
read_permit: tool_read_permit,
query_matrix: tool_query_matrix,
note: tool_note,
read_scratchpad: tool_read_scratchpad,
done: tool_done,
};
const TOOL_SCHEMA = `Available tools (call by emitting JSON like: {"tool": "name", "args": {...}}):
- list_permits(min_cost?: number, permit_type?: string) — top 5 by cost
- read_permit(permit_id: string) — full permit fields
- query_matrix(query: string, top_k?: number) — search KB
- note(text: string) — append to scratchpad
- read_scratchpad() — read your scratchpad
- done(summary: string) — finish; pass final markdown analysis`;
// ─── AGENT LOOP ───
async function callAgent(messages: Array<{role: string; content: string}>): Promise<string> {
// think:false disables hidden reasoning so all generated tokens go to
// visible response. qwen3.5:latest defaults to thinking and silently
// burns the token budget otherwise.
const r = await fetch(`${SIDECAR}/generate`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
model: AGENT_MODEL,
prompt: messages.map(m => `${m.role.toUpperCase()}:\n${m.content}`).join("\n\n") + "\n\nASSISTANT:\n",
stream: false,
max_tokens: 1500,
think: false,
}),
signal: AbortSignal.timeout(180000),
});
if (!r.ok) throw new Error(`agent ${r.status}: ${(await r.text()).slice(0, 200)}`);
const j: any = await r.json();
return String(j.text ?? j.response ?? "").trim();
}
function extractToolCall(response: string): { tool: string; args: any } | null {
// Look for JSON block in the response
const fenced = response.match(/```(?:json)?\s*(\{[\s\S]+?\})\s*```/);
const candidate = fenced ? fenced[1] : (response.match(/\{[\s\S]*\}/)?.[0] ?? null);
if (!candidate) return null;
try {
const parsed = JSON.parse(candidate);
if (parsed.tool && typeof parsed.tool === "string") return { tool: parsed.tool, args: parsed.args ?? {} };
} catch { /* not JSON */ }
return null;
}
async function main() {
log(`session=${SESSION_ID} model=${AGENT_MODEL} max_steps=${MAX_STEPS}`);
// Reset workspace files for this session
for (const p of [SCRATCHPAD_PATH, TRACE_PATH, FINAL_PATH]) {
try { await Bun.write(p, ""); } catch { /* ignore */ }
}
const prd = await readFile(PRD_PATH, "utf8");
log(`loaded PRD (${prd.length} chars)`);
await emitObserverEvent({ event_kind: "agent_start", model: AGENT_MODEL });
// Pre-flight: pull prior accepted pathway traces for this task class
// and surface them as a "PROVEN APPROACHES" preamble. This closes the
// matrix loop — successful past runs now actively help the next agent.
let priorPlaybooks = "";
try {
const stateFile = Bun.file("/home/profit/lakehouse/data/_pathway_memory/state.json");
if (await stateFile.exists()) {
const state: any = JSON.parse(await stateFile.text());
const matched: any[] = [];
for (const traces of Object.values(state.pathways ?? {}) as any[][]) {
for (const t of traces) {
if (t.task_class === "chicago_permit_analysis" && t.final_verdict === "accepted" && !t.retired) {
matched.push(t);
}
}
}
matched.sort((a, b) => (b.created_at ?? "").localeCompare(a.created_at ?? ""));
if (matched.length > 0) {
const top = matched.slice(0, 2);
priorPlaybooks = "\n\n═══ 📖 PROVEN APPROACHES FROM PRIOR ACCEPTED RUNS ═══\n" +
top.map((t, i) =>
`[${i + 1}] pathway=${t.pathway_id?.slice(0, 12)} previously succeeded on ${t.file_path}\n` +
`Approach excerpt:\n${(t.reducer_summary ?? "").slice(0, 800)}`
).join("\n\n") +
"\n═══ end proven approaches ═══\n\nUse these as REFERENCE for what worked. Don't copy verbatim, but follow the same workflow shape (plan → list → read → matrix → analyze → done).\n";
log(`📖 found ${matched.length} prior accepted pathway(s) for chicago_permit_analysis — top ${top.length} prepended to agent context`);
} else {
log(`📖 no prior accepted pathways for chicago_permit_analysis (this is the first run)`);
}
}
} catch (e: any) {
log(`📖 pathway preamble skipped: ${e.message}`);
}
const systemMsg = `You are an autonomous agent. Read the PRD below and follow its instructions exactly.
${TOOL_SCHEMA}
To call a tool, respond with ONLY a JSON object: {"tool": "<name>", "args": {...}}
No markdown, no explanation around it. The harness will execute the tool and give you the result, then ask you what to do next.
When you are completely finished, call done(summary="<your final markdown>").`;
const messages: Array<{role: string; content: string}> = [
{ role: "system", content: systemMsg },
{ role: "user", content: `PRD:\n\n${prd}${priorPlaybooks}\n\nNow respond. Remember: PLAN first via note() before executing.` },
];
// Iter 3 surfaced: when the matrix returns real evidence, the agent
// gets analysis paralysis — keeps calling note() to refine instead of
// producing the final output. Guard: after MAX_CONSECUTIVE_NOTES
// note() calls in a row, harness injects a hard-stop user message
// telling the agent it MUST call done() next.
const MAX_CONSECUTIVE_NOTES = Number(process.env.AGENT_MAX_CONSECUTIVE_NOTES ?? 2);
let consecutiveNotes = 0;
let isDone = false;
for (let step = 1; step <= MAX_STEPS && !isDone; step++) {
log(`step ${step}/${MAX_STEPS} — calling agent...`);
const t0 = Date.now();
let response: string;
try {
response = await callAgent(messages);
} catch (e: any) {
log(` ✗ agent error: ${e.message}`);
await trace({ step, kind: "error", error: e.message });
await emitObserverEvent({ event_kind: "agent_error", step, error: e.message });
break;
}
const ms = Date.now() - t0;
log(` · agent responded ${response.length} chars in ${ms}ms`);
await trace({ step, kind: "agent_response", chars: response.length, latency_ms: ms, response: response.slice(0, 4000) });
const call = extractToolCall(response);
if (!call) {
log(` ⚠ no tool call extracted from response — agent may be confused`);
await trace({ step, kind: "no_tool_call", preview: response.slice(0, 500) });
await emitObserverEvent({ event_kind: "agent_no_tool", step, preview: response.slice(0, 200) });
// Push the agent: tell it to call a tool
messages.push({ role: "assistant", content: response });
messages.push({ role: "user", content: `Your last response did not contain a valid tool call. Respond with ONLY a JSON object like {"tool": "note", "args": {"text": "..."}}. No prose around it.` });
continue;
}
log(` → tool: ${call.tool}(${JSON.stringify(call.args).slice(0, 200)})`);
if (!TOOLS[call.tool]) {
const err = `unknown tool: ${call.tool}`;
log(` ✗ ${err}`);
await trace({ step, kind: "tool_unknown", tool: call.tool });
await emitObserverEvent({ event_kind: "tool_unknown", step, tool: call.tool });
messages.push({ role: "assistant", content: response });
messages.push({ role: "user", content: `Tool "${call.tool}" does not exist. Available: ${Object.keys(TOOLS).join(", ")}. Try again.` });
continue;
}
const resStart = Date.now();
let result: string;
try {
result = await TOOLS[call.tool](call.args);
} catch (e: any) {
result = `TOOL ERROR: ${e.message}`;
}
const resMs = Date.now() - resStart;
log(` ← ${result.slice(0, 200)}${result.length > 200 ? "..." : ""} (${resMs}ms)`);
await trace({ step, kind: "tool_call", tool: call.tool, args: call.args, result: result.slice(0, 4000), latency_ms: resMs });
await emitObserverEvent({ event_kind: "tool_call", step, tool: call.tool, result_chars: result.length });
if (call.tool === "done") {
isDone = true;
log(` ✓ DONE`);
await emitObserverEvent({ event_kind: "agent_done", step });
break;
}
// Track consecutive note() calls; force done() if too many in a row.
if (call.tool === "note") consecutiveNotes++;
else consecutiveNotes = 0;
messages.push({ role: "assistant", content: response });
if (consecutiveNotes >= MAX_CONSECUTIVE_NOTES) {
log(` ⚠ ${consecutiveNotes} consecutive note() calls — forcing done() next`);
await emitObserverEvent({ event_kind: "force_done_pressure", step, consecutive_notes: consecutiveNotes });
messages.push({ role: "user", content: `Tool result:\n${result}\n\nYou have called note() ${consecutiveNotes} times in a row without producing output. STOP NOTING. Call done(summary="<your final markdown>") NOW with whatever analysis you have. Do not call note() again. Respond with ONLY: {"tool": "done", "args": {"summary": "..."}}` });
consecutiveNotes = 0; // reset so we only push once per streak
} else {
messages.push({ role: "user", content: `Tool result:\n${result}\n\nWhat next?` });
}
}
if (!isDone) {
log(`✗ agent did not complete within ${MAX_STEPS} steps`);
await emitObserverEvent({ event_kind: "agent_max_steps", final_step: MAX_STEPS });
// Mem0: any partial trace this session inserted should be retired
// so future agents don't get a broken playbook in their preamble.
// We don't have a trace_uid for this session yet (insert happens
// on done); but if any prior trace has the same workflow shape as
// this session's tool sequence, retire it.
// For now, just log — actual retirement would happen if seal had run.
log(` ⚠ no playbook seal will be performed for failed run`);
}
log(`session ${SESSION_ID} ended. Trace: ${TRACE_PATH}`);
if (existsSync(FINAL_PATH)) log(`Final output: ${FINAL_PATH}`);
}
mkdirSync("/home/profit/lakehouse/tests/agent_test", { recursive: true });
main().catch(e => { console.error(`FATAL: ${e.message}`); process.exit(1); });
-228
View File
@@ -1,228 +0,0 @@
// Architecture smoke test — exercises the lakehouse substrate against
// the 500k-worker reference dataset. Proves the architecture's core
// claims from docs/PRD.md §Shared requirements end-to-end in <60s,
// without cloud calls.
//
// Each section tests ONE invariant. PASS or FAIL printed per section
// with the actual measurement. A FAIL tells us exactly where the
// architecture broke; the failure is the driver for the next commit.
//
// Run: bun run tests/architecture_smoke.ts
//
// Expected preconditions (checked at top):
// - gateway on :3100
// - sidecar on :3200 (for embed endpoint)
// - workers_500k dataset registered in catalog (500k rows)
// - workers_500k_v1 vector index built (50k chunks, 768d nomic)
// - primary bucket accessible
const GATEWAY = process.env.GATEWAY_URL ?? "http://localhost:3100";
const SIDECAR = process.env.SIDECAR_URL ?? "http://localhost:3200";
type Check = {
name: string;
ok: boolean;
measure?: string;
err?: string;
};
const results: Check[] = [];
function record(name: string, ok: boolean, measure?: string, err?: string) {
results.push({ name, ok, measure, err });
const marker = ok ? "✓" : "✗";
const detail = ok ? (measure ?? "") : (err ?? "");
console.log(` ${marker} ${name.padEnd(50)} ${detail}`);
}
async function http<T = any>(path: string, init?: RequestInit): Promise<T> {
const r = await fetch(`${GATEWAY}${path}`, {
...init,
headers: { "content-type": "application/json", ...(init?.headers ?? {}) },
});
if (!r.ok) throw new Error(`${path} ${r.status}: ${await r.text().catch(() => "?")}`);
return r.json() as Promise<T>;
}
async function main() {
const t0 = Date.now();
console.log(`\n━━━ Architecture smoke test ━━━`);
console.log(`Gateway: ${GATEWAY}`);
console.log(`Sidecar: ${SIDECAR}`);
console.log();
// ─── 1. Preconditions ─────────────────────────────────────────
console.log("【 1 · preconditions 】");
try {
const h = await fetch(`${GATEWAY}/health`);
record("gateway /health", h.ok, `HTTP ${h.status}`);
} catch (e) {
record("gateway /health", false, undefined, String(e));
}
try {
const h = await fetch(`${SIDECAR}/health`);
record("sidecar /health", h.ok, `HTTP ${h.status}`);
} catch (e) {
record("sidecar /health", false, undefined, String(e));
}
// ─── 2. Catalog — invariant: any dataset is queryable by name ──
console.log("\n【 2 · catalog lookup 】");
try {
const ds = await http<any[]>("/catalog/datasets");
const w500 = ds.find((d) => d.name === "workers_500k");
if (w500) {
record("workers_500k in catalog", true, `${w500.row_count ?? "?"} rows`);
} else {
record("workers_500k in catalog", false, undefined, "not found");
}
} catch (e) {
record("workers_500k in catalog", false, undefined, String(e));
}
// ─── 3. SQL at scale — PRD §shared: millions of rows, sub-second ──
console.log("\n【 3 · SQL at scale 】");
await timed("count(*) on workers_500k", async () => {
const j = await http<any>("/query/sql", {
method: "POST",
body: JSON.stringify({ sql: "SELECT COUNT(*) as n FROM workers_500k" }),
});
const n = j?.rows?.[0]?.n ?? 0;
if (n < 400_000) throw new Error(`only ${n} rows — expected ~500k`);
return `${n.toLocaleString()} rows`;
});
await timed("geo filter on workers_500k", async () => {
const j = await http<any>("/query/sql", {
method: "POST",
body: JSON.stringify({
sql: "SELECT COUNT(*) as n FROM workers_500k WHERE state = 'OH'",
}),
});
return `${(j?.rows?.[0]?.n ?? 0).toLocaleString()} Ohio workers`;
});
// ─── 4. Vector search — PRD §shared: AI embeddings over a profile index ──
// Gateway endpoint takes TEXT + embeds internally (uses the sidecar's
// /embed under the hood). We don't pre-embed on the client side.
console.log("\n【 4 · vector search 】");
await timed("vector /vectors/search on workers_500k_v1", async () => {
const j = await http<any>("/vectors/search", {
method: "POST",
body: JSON.stringify({
index_name: "workers_500k_v1",
query: "experienced welder for industrial work",
top_k: 10,
}),
});
const results = j?.results ?? [];
if (!Array.isArray(results) || results.length === 0) {
throw new Error(`no results: ${JSON.stringify(j).slice(0, 200)}`);
}
return `${results.length} results`;
});
// ─── 5. Hybrid SQL+vector — PRD §shared: scoped view via filter ──
console.log("\n【 5 · hybrid search 】");
await timed("hybrid: 'welder in Toledo, OH', SQL-filtered", async () => {
const j = await http<any>("/vectors/hybrid", {
method: "POST",
body: JSON.stringify({
index_name: "workers_500k_v1",
question: "experienced welder",
sql_filter: "state = 'OH' AND city = 'Toledo'",
k: 10,
}),
});
const results = j?.results ?? j?.hits ?? [];
if (!Array.isArray(results)) {
throw new Error(`malformed response: ${JSON.stringify(j).slice(0, 200)}`);
}
return `${results.length} Toledo-OH hits`;
});
// ─── 6. Playbook memory — PRD §shared: trials as first-class data ──
console.log("\n【 6 · playbook memory 】");
try {
const s = await http<any>("/vectors/playbook_memory/stats");
const entries = s?.entries_count ?? s?.count ?? 0;
record("playbook_memory populated", true, `${entries} entries`);
} catch (e) {
record("playbook_memory populated", false, undefined, String(e));
}
// ─── 7. Pathway memory (ADR-021) — the compounding layer ──
console.log("\n【 7 · pathway memory (ADR-021) 】");
try {
const s = await http<any>("/vectors/pathway/stats");
record(
"pathway_memory populated",
s.total_pathways > 0,
`${s.total_pathways} traces, ${s.retired} retired, ${s.with_audit_pass} audit-pass`,
);
// Probe the bug_fingerprints endpoint we rely on for preamble
const bf = await http<any>("/vectors/pathway/bug_fingerprints", {
method: "POST",
body: JSON.stringify({
task_class: "scrum_review",
file_path: "crates/queryd/src/delta.rs",
signal_class: null,
limit: 5,
}),
});
record(
"bug_fingerprints endpoint",
Array.isArray(bf.fingerprints),
`${bf.fingerprints?.length ?? 0} patterns`,
);
} catch (e) {
record("pathway_memory stats", false, undefined, String(e));
}
// ─── 8. Truth gate — SQL safety on query path ──
console.log("\n【 8 · truth gate (SQL safety) 】");
try {
const r = await fetch(`${GATEWAY}/query/sql`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sql: "DROP TABLE workers_500k" }),
});
record(
"destructive SQL blocked on /query/sql",
r.status === 403,
`HTTP ${r.status}`,
r.status === 403 ? undefined : `expected 403, got ${r.status}`,
);
} catch (e) {
record("destructive SQL blocked on /query/sql", false, undefined, String(e));
}
// ─── Summary ─────────────────────────────────────────────────
const elapsed = Date.now() - t0;
const passed = results.filter((r) => r.ok).length;
const failed = results.filter((r) => !r.ok).length;
console.log();
console.log(`━━━ Summary ━━━`);
console.log(` ${passed}/${passed + failed} passed · ${elapsed}ms elapsed`);
if (failed > 0) {
console.log();
console.log(`FAILED checks — these are the next things to fix:`);
for (const r of results) {
if (!r.ok) console.log(` ✗ ${r.name}: ${r.err ?? "?"}`);
}
process.exit(1);
}
console.log(`✓ architecture smoke passed`);
}
async function timed(name: string, fn: () => Promise<string | undefined>) {
const t0 = Date.now();
try {
const detail = await fn();
const ms = Date.now() - t0;
record(name, true, `${ms}ms ${detail ? `· ${detail}` : ""}`);
} catch (e) {
const ms = Date.now() - t0;
record(name, false, `${ms}ms`, String(e));
}
}
await main();
+23 -21
View File
@@ -394,15 +394,11 @@ export async function generate(model: string, prompt: string, opts: {
return text;
}
// Cloud generate — routes through the lakehouse gateway's /v1/chat
// with provider="ollama_cloud". Phase 44 migration (2026-04-24): was
// hitting OLLAMA_CLOUD_URL/api/generate directly with a bearer key,
// bypassing the gateway's usage tracking + audit path. Now every call
// flows through /v1/chat so /v1/usage accounts for it. Gateway holds
// the OLLAMA_CLOUD_KEY; caller no longer needs it in env.
//
// Thinking-model budget note: num_predict ≥ 400 still matters, just
// expressed via max_tokens on the /v1/chat request.
// Cloud generate — hits Ollama Cloud directly with the bearer key. Same
// /api/generate shape as local Ollama; `thinking` field (for gpt-oss:Nb)
// is discarded, only `response` is returned. Caller should budget
// num_predict ≥ 400 so thinking-model reasoning has room before the
// visible response starts.
export async function generateCloud(model: string, prompt: string, opts: {
max_tokens?: number;
temperature?: number;
@@ -410,35 +406,41 @@ export async function generateCloud(model: string, prompt: string, opts: {
bypass_budget?: boolean;
think?: boolean;
} = {}): Promise<string> {
if (!OLLAMA_CLOUD_KEY) {
throw new Error("OLLAMA_CLOUD_KEY not set; cannot reach Ollama Cloud");
}
assertContextBudget(model, prompt, {
system: opts.system,
max_tokens: opts.max_tokens,
bypass: opts.bypass_budget,
});
const messages: Array<{ role: string; content: string }> = [];
if (opts.system) messages.push({ role: "system", content: opts.system });
messages.push({ role: "user", content: prompt });
const body: Record<string, any> = {
model,
messages,
provider: "ollama_cloud",
temperature: opts.temperature ?? 0.3,
max_tokens: Math.max(opts.max_tokens ?? 800, 400),
prompt,
stream: false,
options: {
temperature: opts.temperature ?? 0.3,
num_predict: Math.max(opts.max_tokens ?? 800, 400),
},
};
if (opts.system) body.system = opts.system;
if (opts.think !== undefined) body.think = opts.think;
const resp = await fetch(`${GATEWAY}/v1/chat`, {
const resp = await fetch(`${OLLAMA_CLOUD_URL}/api/generate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: {
"Authorization": `Bearer ${OLLAMA_CLOUD_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!resp.ok) {
throw new Error(`gateway /v1/chat ${resp.status}: ${await resp.text().catch(() => "?")}`);
throw new Error(`Ollama Cloud ${resp.status}: ${await resp.text().catch(() => "?")}`);
}
const data: any = await resp.json();
const text = data?.choices?.[0]?.message?.content ?? "";
const text = typeof data.response === "string" ? data.response : "";
// Same non-throw policy as local generate() — empty text is a valid
// signal that thinking ate the budget. Let generateContinuable retry.
return typeof text === "string" ? text : "";
return text;
}
// --- Prompt construction ---
+86 -86
View File
@@ -1,126 +1,126 @@
{
"count": 20,
"seed": 42,
"seed": 1337,
"scenarios": [
{
"file": "scen_000_Heritage_Foods_Indianapolis.json",
"client": "Heritage Foods",
"city": "Indianapolis",
"events": 5
},
{
"file": "scen_001_Great_Lakes_Mfg_Madison.json",
"file": "scen_000_Great_Lakes_Mfg_Cincinnati.json",
"client": "Great Lakes Mfg",
"city": "Madison",
"events": 2
},
{
"file": "scen_002_Vanguard_Components_Lexington.json",
"client": "Vanguard Components",
"city": "Lexington",
"events": 2
},
{
"file": "scen_003_Cornerstone_Fabrication_Fort_Wayne.json",
"client": "Cornerstone Fabrication",
"city": "Fort Wayne",
"city": "Cincinnati",
"events": 4
},
{
"file": "scen_004_Horizon_Supply_Louisville.json",
"client": "Horizon Supply",
"city": "Louisville",
"events": 3
},
{
"file": "scen_005_Summit_Industrial_Akron.json",
"client": "Summit Industrial",
"city": "Akron",
"file": "scen_001_Parallel_Machining_Joliet.json",
"client": "Parallel Machining",
"city": "Joliet",
"events": 2
},
{
"file": "scen_006_Centennial_Packaging_Flint.json",
"client": "Centennial Packaging",
"city": "Flint",
"file": "scen_002_Summit_Industrial_Cincinnati.json",
"client": "Summit Industrial",
"city": "Cincinnati",
"events": 3
},
{
"file": "scen_007_Pioneer_Assembly_Grand_Rapids.json",
"file": "scen_003_Pioneer_Assembly_Chicago.json",
"client": "Pioneer Assembly",
"city": "Grand Rapids",
"city": "Chicago",
"events": 1
},
{
"file": "scen_008_Cornerstone_Fabrication_Grand_Rapids.json",
"client": "Cornerstone Fabrication",
"city": "Grand Rapids",
"events": 3
},
{
"file": "scen_009_Midway_Distribution_Fort_Wayne.json",
"file": "scen_004_Midway_Distribution_Columbus.json",
"client": "Midway Distribution",
"city": "Fort Wayne",
"city": "Columbus",
"events": 2
},
{
"file": "scen_005_Apex_Warehouse_Cleveland.json",
"client": "Apex Warehouse",
"city": "Cleveland",
"events": 3
},
{
"file": "scen_010_Keystone_Plastics_Lexington.json",
"client": "Keystone Plastics",
"city": "Lexington",
"file": "scen_006_Pioneer_Assembly_Flint.json",
"client": "Pioneer Assembly",
"city": "Flint",
"events": 5
},
{
"file": "scen_011_Cornerstone_Fabrication_Toledo.json",
"client": "Cornerstone Fabrication",
"file": "scen_007_Riverfront_Steel_Toledo.json",
"client": "Riverfront Steel",
"city": "Toledo",
"events": 3
},
{
"file": "scen_012_Heritage_Foods_Gary.json",
"client": "Heritage Foods",
"city": "Gary",
"events": 3
},
{
"file": "scen_013_Riverfront_Steel_Columbus.json",
"client": "Riverfront Steel",
"city": "Columbus",
"events": 3
},
{
"file": "scen_014_Keystone_Plastics_Cincinnati.json",
"client": "Keystone Plastics",
"city": "Cincinnati",
"events": 2
},
{
"file": "scen_015_Beacon_Freight_Detroit.json",
"client": "Beacon Freight",
"city": "Detroit",
"file": "scen_008_Northland_Logistics_Indianapolis.json",
"client": "Northland Logistics",
"city": "Indianapolis",
"events": 4
},
{
"file": "scen_016_Parallel_Machining_Grand_Rapids.json",
"file": "scen_009_Parallel_Machining_Flint.json",
"client": "Parallel Machining",
"city": "Grand Rapids",
"city": "Flint",
"events": 3
},
{
"file": "scen_017_Parallel_Machining_Gary.json",
"client": "Parallel Machining",
"city": "Gary",
"events": 3
},
{
"file": "scen_018_Cornerstone_Fabrication_Louisville.json",
"client": "Cornerstone Fabrication",
"city": "Louisville",
"events": 5
},
{
"file": "scen_019_Summit_Industrial_Kansas_City.json",
"client": "Summit Industrial",
"city": "Kansas City",
"file": "scen_010_Northland_Logistics_Chicago.json",
"client": "Northland Logistics",
"city": "Chicago",
"events": 2
},
{
"file": "scen_011_Heritage_Foods_Flint.json",
"client": "Heritage Foods",
"city": "Flint",
"events": 3
},
{
"file": "scen_012_Parallel_Machining_Kansas_City.json",
"client": "Parallel Machining",
"city": "Kansas City",
"events": 3
},
{
"file": "scen_013_Horizon_Supply_Flint.json",
"client": "Horizon Supply",
"city": "Flint",
"events": 3
},
{
"file": "scen_014_Midway_Distribution_Indianapolis.json",
"client": "Midway Distribution",
"city": "Indianapolis",
"events": 4
},
{
"file": "scen_015_Cornerstone_Fabrication_Kansas_City.json",
"client": "Cornerstone Fabrication",
"city": "Kansas City",
"events": 4
},
{
"file": "scen_016_Riverfront_Steel_Columbus.json",
"client": "Riverfront Steel",
"city": "Columbus",
"events": 4
},
{
"file": "scen_017_Summit_Industrial_Detroit.json",
"client": "Summit Industrial",
"city": "Detroit",
"events": 2
},
{
"file": "scen_018_Heritage_Foods_Cincinnati.json",
"client": "Heritage Foods",
"city": "Cincinnati",
"events": 4
},
{
"file": "scen_019_Midway_Distribution_Chicago.json",
"client": "Midway Distribution",
"city": "Chicago",
"events": 3
}
]
}
-214
View File
@@ -1,214 +0,0 @@
#!/usr/bin/env bun
// Autonomous scrum loop — wraps scrum_master_pipeline.ts + scrum_applier.ts
// in a goal-driven retry loop. Observer is POSTed an iteration summary at
// every boundary so it can build meta-commentary outside the loop's epistemic
// scope.
//
// Usage:
// LOOP_TARGETS="crates/a/src/x.rs,crates/b/src/y.rs" \
// LOOP_MAX_ITERS=5 \
// LOOP_PUSH=1 \
// bun run tests/real-world/autonomous_loop.ts
//
// Stop conditions: max_iters reached OR 2 consecutive iters with 0 commits.
import { spawn } from "node:child_process";
import { appendFile, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
const REPO = "/home/profit/lakehouse";
const OBSERVER = process.env.LOOP_OBSERVER ?? "http://localhost:3800";
const BRANCH = process.env.LOOP_BRANCH ?? "scrum/auto-apply-19814";
const MAX_ITERS = Number(process.env.LOOP_MAX_ITERS ?? 3);
const PUSH = process.env.LOOP_PUSH === "1";
const MIN_CONF = process.env.LOOP_MIN_CONF ?? "85";
// Optional override — when unset, let scrum_applier.ts use ITS default
// (currently x-ai/grok-4.1-fast on openrouter). The prior hardcoded
// qwen3-coder:480b default was clobbering the applier's own default
// and forcing every iter to hit the throttled ollama_cloud account.
const APPLIER_MODEL = process.env.LOOP_APPLIER_MODEL;
const APPLIER_PROVIDER = process.env.LOOP_APPLIER_PROVIDER;
const TARGETS = (process.env.LOOP_TARGETS ?? "crates/queryd/src/service.rs,crates/gateway/src/main.rs,crates/gateway/src/v1/mod.rs")
.split(",").map(s => s.trim()).filter(Boolean);
const FORENSIC = process.env.LH_SCRUM_FORENSIC ?? `${REPO}/docs/SCRUM_FORENSIC_PROMPT.md`;
const PROPOSAL = process.env.LH_SCRUM_PROPOSAL ?? `${REPO}/docs/SCRUM_FIX_WAVE.md`;
const LOOP_ID = `loop_${Date.now().toString(36)}`;
const JOURNAL = `${REPO}/data/_kb/autonomous_loops.jsonl`;
interface IterResult {
iter: number;
scrum_reviews_added: number;
applier_outcomes: Record<string, number>;
commits_landed: number;
commit_shas: string[];
build_status: "green" | "red" | "unknown";
duration_ms: number;
}
function log(msg: string) {
const ts = new Date().toISOString().slice(11, 19);
console.log(`[loop ${LOOP_ID} ${ts}] ${msg}`);
}
function runCmd(cmd: string, args: string[], env: Record<string, string> = {}): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
const child = spawn(cmd, args, { cwd: REPO, env: { ...process.env, ...env } });
let stdout = "", stderr = "";
child.stdout.on("data", (d) => { stdout += d; process.stdout.write(d); });
child.stderr.on("data", (d) => { stderr += d; process.stderr.write(d); });
child.on("close", (code) => resolve({ code: code ?? -1, stdout, stderr }));
});
}
async function countLines(path: string): Promise<number> {
if (!existsSync(path)) return 0;
const text = await readFile(path, "utf8");
return text.split("\n").filter(Boolean).length;
}
async function gitHeadSha(): Promise<string> {
const r = await runCmd("git", ["rev-parse", "HEAD"]);
return r.stdout.trim();
}
async function commitsSince(baseSha: string): Promise<string[]> {
const r = await runCmd("git", ["log", "--oneline", `${baseSha}..HEAD`]);
return r.stdout.trim().split("\n").filter(Boolean);
}
async function cargoCheckGreen(): Promise<boolean> {
log("cargo check --workspace …");
const r = await runCmd("cargo", ["check", "--workspace", "--quiet"]);
return r.code === 0;
}
async function postObserver(payload: object) {
try {
const r = await fetch(`${OBSERVER}/event`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000),
});
if (!r.ok) log(`observer POST returned ${r.status}`);
} catch (e: any) {
log(`observer POST failed: ${e.message}`);
}
}
async function runIter(iter: number, baseSha: string): Promise<IterResult> {
const t0 = Date.now();
log(`══ iter ${iter} start (base ${baseSha.slice(0, 8)}) targets=${TARGETS.length}`);
const reviewsBefore = await countLines(`${REPO}/data/_kb/scrum_reviews.jsonl`);
const applyBefore = await countLines(`${REPO}/data/_kb/auto_apply.jsonl`);
log(`scrum_master_pipeline.ts → ${TARGETS.length} files`);
await runCmd("bun", ["run", "tests/real-world/scrum_master_pipeline.ts"], {
LH_SCRUM_FILES: TARGETS.join(","),
LH_SCRUM_FORENSIC: FORENSIC,
LH_SCRUM_PROPOSAL: PROPOSAL,
});
log(`scrum_applier.ts COMMIT=1 MIN_CONF=${MIN_CONF} files=${TARGETS.length}`);
// Only forward model/provider when explicitly overridden — otherwise
// let scrum_applier.ts use its own defaults (Grok 4.1 fast on openrouter).
const applierEnv: Record<string, string> = {
LH_APPLIER_COMMIT: "1",
LH_APPLIER_MIN_CONF: MIN_CONF,
LH_APPLIER_MAX_FILES: String(TARGETS.length),
LH_APPLIER_BRANCH: BRANCH,
// Constrain applier to THIS iter's targets so it patches what we
// just reviewed instead of the highest-confidence file from history.
LH_APPLIER_FILES: TARGETS.join(","),
};
if (APPLIER_MODEL) applierEnv.LH_APPLIER_MODEL = APPLIER_MODEL;
if (APPLIER_PROVIDER) applierEnv.LH_APPLIER_PROVIDER = APPLIER_PROVIDER;
await runCmd("bun", ["run", "tests/real-world/scrum_applier.ts"], applierEnv);
const reviewsAfter = await countLines(`${REPO}/data/_kb/scrum_reviews.jsonl`);
const applyAfterText = existsSync(`${REPO}/data/_kb/auto_apply.jsonl`)
? await readFile(`${REPO}/data/_kb/auto_apply.jsonl`, "utf8")
: "";
const applyRows = applyAfterText.split("\n").filter(Boolean).slice(applyBefore);
const outcomes: Record<string, number> = {};
for (const line of applyRows) {
try {
const o = JSON.parse(line);
outcomes[o.action ?? "?"] = (outcomes[o.action ?? "?"] ?? 0) + 1;
} catch { /* skip malformed */ }
}
const commitShas = await commitsSince(baseSha);
const buildStatus = commitShas.length > 0 ? (await cargoCheckGreen() ? "green" : "red") : "unknown";
const result: IterResult = {
iter,
scrum_reviews_added: reviewsAfter - reviewsBefore,
applier_outcomes: outcomes,
commits_landed: commitShas.length,
commit_shas: commitShas.map(s => s.split(" ")[0]),
build_status: buildStatus,
duration_ms: Date.now() - t0,
};
log(`iter ${iter} done — reviews+${result.scrum_reviews_added} commits=${result.commits_landed} build=${buildStatus} (${(result.duration_ms / 1000).toFixed(1)}s)`);
await postObserver({
source: "autonomous_loop",
loop_id: LOOP_ID,
event_kind: "iteration_complete",
iter,
targets: TARGETS,
success: buildStatus !== "red",
scrum_reviews_added: result.scrum_reviews_added,
applier_outcomes: result.applier_outcomes,
commits_landed: result.commits_landed,
commit_shas: result.commit_shas,
build_status: buildStatus,
duration_ms: result.duration_ms,
ts: new Date().toISOString(),
});
await appendFile(JOURNAL, JSON.stringify({ loop_id: LOOP_ID, ...result, ts: new Date().toISOString() }) + "\n");
return result;
}
async function main() {
log(`autonomous loop starting · branch=${BRANCH} max_iters=${MAX_ITERS} push=${PUSH}`);
log(`targets: ${TARGETS.join(", ")}`);
const branchR = await runCmd("git", ["branch", "--show-current"]);
if (branchR.stdout.trim() !== BRANCH) {
log(`ERROR: on branch ${branchR.stdout.trim()}, expected ${BRANCH}. Refusing to run.`);
process.exit(1);
}
let consecutiveZero = 0;
for (let iter = 1; iter <= MAX_ITERS; iter++) {
const baseSha = await gitHeadSha();
const result = await runIter(iter, baseSha);
if (PUSH && result.commits_landed > 0) {
log(`git push origin ${BRANCH}`);
const pushR = await runCmd("git", ["push", "origin", BRANCH]);
if (pushR.code !== 0) log(`push failed (continuing): ${pushR.stderr.slice(0, 200)}`);
}
consecutiveZero = result.commits_landed === 0 ? consecutiveZero + 1 : 0;
if (consecutiveZero >= 2) {
log(`STOP: 2 consecutive iters with 0 commits. Loop converged or stuck.`);
break;
}
}
log(`loop ${LOOP_ID} complete. Journal: ${JOURNAL}`);
}
main().catch((e) => {
log(`FATAL: ${e.message}`);
process.exit(1);
});
+40 -45
View File
@@ -1,6 +1,6 @@
// Nine-consecutive audit runner — empirical test of the predictive-
// compounding property. Runs the audit pipeline 9 times against the
// same PR (each time with a new diff from Gitea), captures the
// compounding property. Pushes 9 empty commits to the current branch,
// waits for each audit to complete on the new SHA, captures the
// verdict + audit_lessons state after each run, and reports whether
// the KB stabilizes or drifts.
//
@@ -20,31 +20,49 @@
//
// Run: bun run tests/real-world/nine_consecutive_audits.ts
import { readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { readFile } from "node:fs/promises";
import { aggregate } from "../../auditor/kb_index.ts";
import { getPrSnapshot } from "../../auditor/gitea.ts";
import { auditPr } from "../../auditor/audit.ts";
const REPO = "/home/profit/lakehouse";
const AUDIT_LESSONS = `${REPO}/data/_kb/audit_lessons.jsonl`;
const VERDICTS_DIR = `${REPO}/data/_auditor/verdicts`;
const POLL_INTERVAL_MS = 5_000;
const AUDIT_TIMEOUT_MS = 180_000;
const RUNS = Number(process.env.LH_AUDIT_RUNS ?? 9);
const TARGET_PR = Number(process.env.LH_AUDIT_PR ?? 8);
const SKIP_INFERENCE = process.env.LH_AUDITOR_SKIP_INFERENCE !== "0";
const RESET_KB = process.env.LH_RESET_KB === "1";
async function waitForVerdict(prNum: number, sha: string, deadlineMs: number): Promise<any> {
async function sh(cmd: string): Promise<{ stdout: string; stderr: string; code: number }> {
const p = Bun.spawn(["bash", "-lc", cmd], { cwd: REPO, stdout: "pipe", stderr: "pipe" });
const [stdout, stderr] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
const code = await p.exited;
return { stdout, stderr, code };
}
async function getHeadSha(): Promise<string> {
const r = await sh("git rev-parse HEAD");
return r.stdout.trim();
}
async function pushEmptyCommit(n: number): Promise<string> {
const msg = `test: nine-consecutive audit run ${n}/${RUNS} (compounding probe)`;
await sh(`GIT_AUTHOR_NAME=profit GIT_AUTHOR_EMAIL=profit@lakehouse GIT_COMMITTER_NAME=profit GIT_COMMITTER_EMAIL=profit@lakehouse git commit --allow-empty -m "${msg}"`);
const sha = await getHeadSha();
const pushCmd = `PAT="dead60d1160a02f81d241197d5d18f4608794fb2"; git -c credential.helper='!f() { echo "username=profit"; echo "password='$PAT'"; }; f' push origin HEAD 2>&1`;
const pr = await sh(pushCmd);
if (pr.code !== 0) throw new Error(`push failed: ${pr.stderr || pr.stdout}`);
return sha;
}
async function waitForVerdict(sha: string, deadlineMs: number): Promise<any> {
const short = sha.slice(0, 12);
const path = join(VERDICTS_DIR, `${prNum}-${short}.json`);
const path = `${VERDICTS_DIR}/${TARGET_PR}-${short}.json`;
const start = Date.now();
while (Date.now() - start < deadlineMs) {
try {
const raw = await readFile(path, "utf8");
return JSON.parse(raw);
} catch { /* not yet */ }
await Bun.sleep(POLL_INTERVAL_MS);
await new Promise(r => setTimeout(r, POLL_INTERVAL_MS));
}
throw new Error(`no verdict file after ${deadlineMs}ms: ${path}`);
}
@@ -55,14 +73,10 @@ async function captureAggState(): Promise<{ sig_count: number; max_count: number
scopeFn: (r) => (r?.pr_number !== undefined ? `pr-${r.pr_number}` : undefined),
});
const list = Array.from(agg.values()).sort((a, b) => b.count - a.count);
const recurring = list.filter(r => r.count >= 2);
const recurringMaxCount = recurring.length > 0 ? Math.max(...recurring.map(a => a.count)) : 0;
const recurringMaxConf = recurring.length > 0 ? Math.max(...recurring.map(a => a.confidence)) : 0;
return {
sig_count: list.length,
max_count: list[0]?.count ?? 0,
max_confidence: recurringMaxConf,
recurring_max_count: recurringMaxCount,
max_confidence: list.reduce((m, a) => Math.max(m, a.confidence), 0),
top3: list.slice(0, 3).map(a => ({
sig: a.signature,
count: a.count,
@@ -86,25 +100,12 @@ interface RunRecord {
kb_sig_count_after: number;
kb_max_count_after: number;
kb_max_confidence_after: number;
kb_recurring_max_count: number;
}
async function main() {
console.log(`[nine] target PR: #${TARGET_PR}`);
console.log(`[nine] runs: ${RUNS}`);
console.log(`[nine] skip_inference: ${SKIP_INFERENCE}`);
console.log(`[nine] reset_kb: ${RESET_KB}`);
console.log(`[nine] audit_lessons.jsonl: ${AUDIT_LESSONS}`);
if (RESET_KB) {
console.log("[nine] clearing audit_lessons.jsonl for clean test...");
await writeFile(AUDIT_LESSONS, "");
}
console.log("");
const pr = await getPrSnapshot(TARGET_PR);
console.log(`[nine] PR #${pr.number}: "${pr.title}" (head=${pr.head_sha.slice(0, 12)})`);
console.log(`[nine] files in diff: ${pr.files.length}`);
console.log("");
const baseline = await captureAggState();
@@ -115,18 +116,13 @@ async function main() {
for (let n = 1; n <= RUNS; n++) {
const t0 = Date.now();
console.log(`─── run ${n}/${RUNS} ───`);
const verdict = await auditPr(pr, {
dry_run: true,
skip_dynamic: true,
skip_inference: SKIP_INFERENCE,
});
console.log(` sha ${verdict.head_sha.slice(0, 12)}`);
const sha = await pushEmptyCommit(n);
console.log(` pushed ${sha.slice(0, 12)}`);
const verdict = await waitForVerdict(sha, AUDIT_TIMEOUT_MS);
const after = await captureAggState();
const rec: RunRecord = {
run: n,
sha: verdict.head_sha.slice(0, 12),
sha: sha.slice(0, 12),
verdict_overall: String(verdict.overall),
findings_total: Number(verdict.metrics?.findings_total ?? 0),
findings_block: Number(verdict.metrics?.findings_block ?? 0),
@@ -138,11 +134,10 @@ async function main() {
kb_sig_count_after: after.sig_count,
kb_max_count_after: after.max_count,
kb_max_confidence_after: after.max_confidence,
kb_recurring_max_count: after.recurring_max_count,
};
records.push(rec);
console.log(` verdict=${rec.verdict_overall} findings=${rec.findings_total} (b=${rec.findings_block} w=${rec.findings_warn})`);
console.log(` kb after: sig=${rec.kb_sig_count_after} max_count=${rec.kb_max_count_after} recurring_max=${rec.kb_recurring_max_count} max_conf=${rec.kb_max_confidence_after.toFixed(2)}`);
console.log(` kb after: sig=${rec.kb_sig_count_after} max_count=${rec.kb_max_count_after} max_conf=${rec.kb_max_confidence_after.toFixed(2)}`);
console.log(` elapsed: ${((Date.now() - t0) / 1000).toFixed(1)}s`);
console.log("");
}
@@ -158,10 +153,10 @@ async function main() {
console.log("");
console.log("═══ COMPOUNDING PROPERTY ═══");
const sigDelta = records[records.length - 1].kb_sig_count_after - baseline.sig_count;
const maxCount = records[records.length - 1].kb_max_count_after;
const maxConf = records[records.length - 1].kb_max_confidence_after;
const recurringMax = records[records.length - 1].kb_recurring_max_count;
console.log(` signatures added over ${RUNS} runs: ${sigDelta}`);
console.log(` max recurring count after run ${RUNS}: ${recurringMax} (same-PR recurrences per signature)`);
console.log(` max count after run ${RUNS}: ${maxCount} (same-PR recurrences per signature)`);
console.log(` max confidence after run ${RUNS}: ${maxConf.toFixed(2)} (expect LOW — same-PR should not inflate)`);
const verdictSet = new Set(records.map(r => r.verdict_overall));
@@ -171,10 +166,10 @@ async function main() {
console.log(` verdict oscillated across runs: ${[...verdictSet].join(" | ")} ✗`);
}
if (maxConf < 0.6 && recurringMax < 5) {
if (maxConf < 0.3) {
console.log(` confidence policy holding: same-PR noise stays below escalation threshold ✓`);
} else {
console.log(` ⚠ cross-cutting pattern detected (conf=${maxConf.toFixed(2)}, recurring=${recurringMax}) — kb_index policy escalated`);
console.log(` ⚠ confidence escalated above 0.3 on same-PR noise — kb_index policy needs tightening`);
}
const jsonOut = `${REPO}/tests/real-world/runs/nine_consecutive_${Date.now().toString(36)}.json`;
@@ -183,4 +178,4 @@ async function main() {
console.log(` report: ${jsonOut}`);
}
main().catch(e => { console.error("[nine] fatal:", e); process.exit(1); });
main().catch(e => { console.error("[nine] fatal:", e); process.exit(1); });
-487
View File
@@ -1,487 +0,0 @@
// scrum_applier.ts — the auto-apply pipeline.
//
// Turns the scrum master's signal into real commits. Reads
// data/_kb/scrum_reviews.jsonl, filters to rows where the scrum's
// own confidence is high enough to trust auto-apply (gradient_tier
// auto OR confidence_avg ≥ 90), asks a patch-emitting model to
// produce concrete old_string/new_string pairs, applies them via
// text replacement, runs `cargo check` after each, commits on green
// and reverts on red.
//
// Runs on its own branch (never on main). Every action is recorded
// in data/_kb/auto_apply.jsonl so the auditor and future iterations
// can see what landed and what reverted.
//
// Usage:
// bun run tests/real-world/scrum_applier.ts # dry-run, print only
// LH_APPLIER_COMMIT=1 bun run tests/real-world/scrum_applier.ts # actually apply
//
// Env:
// LH_APPLIER_BRANCH — branch name (default: "scrum/auto-apply-${Date.now()}")
// LH_APPLIER_MIN_CONF — minimum confidence_avg, default 90
// LH_APPLIER_MAX_FILES — cap on files per run (default 5, keeps diffs reviewable)
// LH_APPLIER_COMMIT — "1" to actually commit; otherwise dry-run
// LH_APPLIER_MODEL — patch-emitting model (default: kimi-k2:1t)
import { readFile, writeFile, appendFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { spawn } from "node:child_process";
const REPO = "/home/profit/lakehouse";
const GATEWAY = "http://localhost:3100";
const SCRUM_REVIEWS = `${REPO}/data/_kb/scrum_reviews.jsonl`;
const AUDIT_LOG = `${REPO}/data/_kb/auto_apply.jsonl`;
const MIN_CONF = Number(process.env.LH_APPLIER_MIN_CONF ?? 90);
const MAX_FILES = Number(process.env.LH_APPLIER_MAX_FILES ?? 5);
const COMMIT = process.env.LH_APPLIER_COMMIT === "1";
// LH_APPLIER_FILES — comma-separated repo-relative paths. When set,
// constrains eligible reviews to ONLY those files. Used by the autonomous
// loop so the applier patches what scrum just reviewed in this iter,
// instead of pulling the highest-confidence file from global review history.
const TARGET_FILES = (process.env.LH_APPLIER_FILES ?? "")
.split(",").map(s => s.trim()).filter(Boolean);
// Default patch-emitter model — qwen3-coder:480b is the coding specialist
// in the scrum ladder (rung 2). Swapped in from kimi-k2:1t after 2026-04-24
// data showed kimi-k2:1t produces architectural patches that cascade across
// the file (rename a field → 20 broken call sites). qwen3-coder is tuned
// for targeted code changes and tends to stay within the mechanical-patch
// constraint the prompt asks for. LLM Team's /api/run?mode=patch would be
// the ideal choice but that mode isn't registered in llm_team_ui.py yet.
// Default patch emitter swapped to OpenRouter Grok 4.1 fast (2026-04-25)
// after observing the prior default (ollama_cloud::qwen3-coder:480b) sit
// at 429 throttle and never produce patches. Grok 4.1 fast: $0.20/$0.50
// per M, 2M ctx, proven to emit precise structured patches in observer
// hand-review tests. Override with LH_APPLIER_MODEL + LH_APPLIER_PROVIDER.
const MODEL = process.env.LH_APPLIER_MODEL ?? "x-ai/grok-4.1-fast";
const PROVIDER = (process.env.LH_APPLIER_PROVIDER ?? "openrouter") as "ollama_cloud" | "openrouter" | "ollama";
const BRANCH = process.env.LH_APPLIER_BRANCH ?? `scrum/auto-apply-${Date.now().toString(36)}`;
// Deny-list — anything whose path starts with one of these is skipped
// regardless of how confident the scrum is. Config / systemd / docs /
// auditor itself are off limits for auto-apply; they need a human.
const DENY_PREFIXES = [
"config/",
"ops/",
"auditor/",
"docs/",
"data/",
"/etc/",
"mcp-server/",
"ui/",
"sidecar/",
"scripts/",
];
function log(msg: string) { console.log(`[applier] ${msg}`); }
async function sh(cmd: string[], cwd = REPO): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve) => {
const p = spawn(cmd[0], cmd.slice(1), { cwd, stdio: ["ignore", "pipe", "pipe"] });
let out = ""; let err = "";
p.stdout.on("data", (d) => { out += d.toString(); });
p.stderr.on("data", (d) => { err += d.toString(); });
p.on("close", (code) => resolve({ stdout: out, stderr: err, code: code ?? 1 }));
});
}
async function auditLog(row: Record<string, any>) {
const line = JSON.stringify({ ...row, ts: new Date().toISOString() }) + "\n";
await appendFile(AUDIT_LOG, line);
}
async function chat(opts: {
provider: "ollama_cloud" | "openrouter" | "ollama";
model: string;
prompt: string;
max_tokens?: number;
}): Promise<{ content: string; error?: string }> {
try {
const r = await fetch(`${GATEWAY}/v1/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
provider: opts.provider,
model: opts.model,
messages: [{ role: "user", content: opts.prompt }],
max_tokens: opts.max_tokens ?? 1500,
temperature: 0.1,
}),
signal: AbortSignal.timeout(180000),
});
if (!r.ok) return { content: "", error: `${r.status}: ${(await r.text()).slice(0, 300)}` };
const j: any = await r.json();
return { content: j.choices?.[0]?.message?.content ?? "" };
} catch (e) {
return { content: "", error: String(e) };
}
}
interface ScrumReview {
file: string;
reviewed_at: string;
accepted_model: string;
suggestions_preview: string;
confidences_per_finding?: number[];
confidence_avg?: number | null;
confidence_min?: number | null;
gradient_tier?: string;
gradient_tier_avg?: string;
verdict?: string;
critical_failures_count?: number;
schema_version?: number;
}
async function loadLatestReviews(): Promise<Map<string, ScrumReview>> {
// Map of file → latest review for that file. Ordered by reviewed_at.
if (!existsSync(SCRUM_REVIEWS)) return new Map();
const text = await readFile(SCRUM_REVIEWS, "utf8");
const rows: ScrumReview[] = text.split("\n").filter(Boolean).map(l => {
try { return JSON.parse(l); } catch { return null; }
}).filter((r): r is ScrumReview => r !== null);
// Keep the LATEST review per file.
const latest = new Map<string, ScrumReview>();
for (const r of rows) {
if (!r.file) continue;
const prev = latest.get(r.file);
if (!prev || (r.reviewed_at > prev.reviewed_at)) latest.set(r.file, r);
}
return latest;
}
function passesConfidenceGate(r: ScrumReview): boolean {
const avg = r.confidence_avg ?? 0;
const min = r.confidence_min ?? 0;
// Must be auto or dry_run tier AND confidence_min ≥ MIN_CONF.
// min is the conservative tier-lower-bound (one weak finding drags
// the whole file to "simulation" or "block" tier).
if (r.gradient_tier === "block" || r.gradient_tier === "simulation") return false;
return avg >= MIN_CONF && min >= 70;
}
function passesDenyList(file: string): boolean {
return !DENY_PREFIXES.some((p) => file.startsWith(p) || file === p.replace(/\/$/, ""));
}
interface Patch {
file: string;
old_string: string;
new_string: string;
rationale: string;
confidence: number;
}
async function requestPatches(file: string, source: string, review: string): Promise<Patch[]> {
const prompt = `You previously produced this review of ${file}:
─── REVIEW ───
${review}
─── END REVIEW ───
The review is high-confidence and the file is eligible for auto-apply. Produce CONCRETE PATCHES as JSON so they can be applied via string replacement.
HARD CONSTRAINTS (violations → patch rejected):
1. Output ONE JSON object with a "patches" array. NO prose, no markdown fences.
2. Each patch is {"old_string": "...", "new_string": "...", "rationale": "short", "confidence": 0-100}.
3. "old_string" MUST appear EXACTLY ONCE in the file (verbatim, including whitespace + trailing newlines).
4. Max diff size: 6 lines changed per patch (NOT 20). If the change needs >6 lines, emit {"patches": []} — too risky for auto-apply.
5. Mechanical-only (the list — nothing else):
(a) remove a #[allow(dead_code)] marker on a function now wired elsewhere
(b) add a missing 'use' import statement
(c) add a single field to a struct (no renames)
(d) flip a single boolean/match-arm that doesn't cascade
(e) add a fire-and-forget log or tracing call
6. FORBIDDEN (automatic reject):
• struct field RENAMES (break all call sites)
• function signature changes
• new modules, new traits, new dependencies
• any change that requires editing another file to keep it compiling
• adding a 'use' import whose symbol is NOT referenced in the same patch
or already referenced in the surrounding file (no "import and hope")
• rationale that describes functionality not present in the diff
(if rationale says "destructive SQL filter", the diff must contain SQL/filter tokens)
7. Each "new_string" MUST compile in isolation with the same surrounding code.
8. Max 2 patches per file — quality over quantity.
9. The "rationale" field MUST use vocabulary that appears in the new_string.
Generic rationales like "improve code" or "add missing feature" are rejected.
10. If you cannot produce at least one high-confidence mechanical patch under these constraints, output {"patches": []}. Don't guess.
─── SOURCE (${source.length} bytes) ───
${source.slice(0, 14000)}
─── END SOURCE ───
Emit ONLY the JSON object.`;
const r = await chat({ provider: PROVIDER, model: MODEL, prompt, max_tokens: 2500 });
if (r.error || !r.content) return [];
// Strip markdown fences if model wrapped the JSON.
let raw = r.content.trim();
const fenceStart = raw.match(/^```(?:json)?\s*/);
if (fenceStart) raw = raw.slice(fenceStart[0].length);
if (raw.endsWith("```")) raw = raw.slice(0, -3).trim();
// Find first { and last } to extract JSON block if there's prose.
const first = raw.indexOf("{");
const last = raw.lastIndexOf("}");
if (first >= 0 && last > first) raw = raw.slice(first, last + 1);
try {
const obj = JSON.parse(raw);
const patches: Patch[] = (obj.patches ?? []).filter((p: any) =>
typeof p?.old_string === "string" &&
typeof p?.new_string === "string" &&
p.old_string !== p.new_string &&
p.old_string.length > 0 &&
typeof p?.confidence === "number"
).map((p: any) => ({
file,
old_string: p.old_string,
new_string: p.new_string,
rationale: String(p.rationale ?? ""),
confidence: p.confidence,
}));
return patches;
} catch (e) {
log(` ${file}: patch JSON parse failed — ${String(e).slice(0, 100)}`);
return [];
}
}
async function applyPatches(file: string, patches: Patch[]): Promise<{ applied: number; rejected: Array<{patch: Patch; reason: string}> }> {
const full = `${REPO}/${file}`;
let source = await readFile(full, "utf8");
const rejected: Array<{patch: Patch; reason: string}> = [];
let applied = 0;
for (const p of patches) {
// Confidence gate at the individual-patch level.
if (p.confidence < MIN_CONF) { rejected.push({patch: p, reason: `confidence ${p.confidence} < ${MIN_CONF}`}); continue; }
// Uniqueness gate.
const occurrences = source.split(p.old_string).length - 1;
if (occurrences === 0) { rejected.push({patch: p, reason: "old_string not found"}); continue; }
if (occurrences > 1) { rejected.push({patch: p, reason: `old_string appears ${occurrences}× (not unique)`}); continue; }
// Size gate — no patch touches > 6 lines (diff discipline; matches prompt).
// Raised from 20 to 6 after 2026-04-24 data showed most 10-20 line patches
// cascaded and broke the build. Mechanical changes genuinely fit in 6 lines.
const oldLines = p.old_string.split("\n").length;
const newLines = p.new_string.split("\n").length;
if (Math.max(oldLines, newLines) > 6) { rejected.push({patch: p, reason: `patch too large (${Math.max(oldLines,newLines)} lines, max 6)`}); continue; }
source = source.replace(p.old_string, p.new_string);
applied++;
}
if (applied > 0) await writeFile(full, source);
return { applied, rejected };
}
async function cargoCheck(): Promise<{ green: boolean; warnings: number }> {
const r = await sh(["cargo", "check", "--workspace"]);
// Count warnings: cargo emits "warning:" lines on stderr (and sometimes stdout).
// We count unique "warning: <msg>" opening lines, not continuation context.
const combined = r.stdout + r.stderr;
const warnings = (combined.match(/^warning: /gm) || []).length;
return { green: r.code === 0, warnings };
}
async function gitCommit(file: string, patches: Patch[]): Promise<boolean> {
if (!COMMIT) { log(` (dry-run) would commit ${file}`); return true; }
const addR = await sh(["git", "add", file]);
if (addR.code !== 0) { log(` git add failed: ${addR.stderr.slice(0, 200)}`); return false; }
const msg = `auto-apply: ${patches.length} high-confidence fix${patches.length === 1 ? "" : "es"} in ${file}\n\n${patches.map(p => `- ${p.rationale} (conf ${p.confidence}%)`).join("\n")}\n\n🤖 scrum_applier.ts`;
const commitR = await sh(["git", "commit", "-m", msg]);
if (commitR.code !== 0) { log(` git commit failed: ${commitR.stderr.slice(0, 200)}`); return false; }
log(` ✓ committed ${file}`);
return true;
}
async function revertFile(file: string): Promise<void> {
await sh(["git", "checkout", "--", file]);
}
// Cheap, conservative check that the rationale actually describes the
// diff. Tokenizes the new_string (what's actually being added), drops
// Rust keywords + common filler, and requires the rationale to share
// at least one meaningful token. Catches rationale-diff divergence like
// "Add destructive SQL filter" for a diff that only adds `use tracing;`.
const STOPWORDS = new Set([
"use", "let", "pub", "mut", "fn", "self", "the", "a", "and", "or", "in",
"to", "for", "of", "on", "at", "add", "added", "adds", "new", "is",
"as", "if", "else", "match", "return", "crate", "mod", "struct",
"trait", "impl", "this", "that", "with", "from", "into", "by", "be",
"it", "its", "not", "one", "two", "line", "lines",
]);
function tokenize(s: string): Set<string> {
const out = new Set<string>();
for (const raw of s.toLowerCase().split(/[^a-z0-9_]+/)) {
if (raw.length >= 4 && !STOPWORDS.has(raw)) out.add(raw);
}
return out;
}
function rationaleMatchesDiff(p: Patch): boolean {
// Only check tokens introduced by the patch (what's in new but not old).
const newToks = tokenize(p.new_string);
const oldToks = tokenize(p.old_string);
const added = new Set([...newToks].filter((t) => !oldToks.has(t)));
if (added.size === 0) return true; // pure deletion — rationale unconstrained
const rationaleToks = tokenize(p.rationale);
for (const t of added) if (rationaleToks.has(t)) return true;
return false;
}
async function main() {
log(`starting · min_conf=${MIN_CONF} max_files=${MAX_FILES} model=${MODEL} commit=${COMMIT}`);
if (COMMIT) {
const headR = await sh(["git", "rev-parse", "--abbrev-ref", "HEAD"]);
const currentBranch = headR.stdout.trim();
if (currentBranch === "main") {
log(`refusing to run on main — create a branch first or set LH_APPLIER_BRANCH`);
const coR = await sh(["git", "checkout", "-b", BRANCH]);
if (coR.code !== 0) { log(`could not create branch ${BRANCH}: ${coR.stderr.slice(0, 200)}`); process.exit(1); }
log(`working branch: ${BRANCH}`);
} else {
log(`working branch: ${currentBranch}`);
}
}
const reviews = await loadLatestReviews();
log(`loaded ${reviews.size} latest reviews`);
const eligible = [...reviews.values()].filter(r =>
passesConfidenceGate(r) && passesDenyList(r.file) &&
(TARGET_FILES.length === 0 || TARGET_FILES.includes(r.file))
).sort((a, b) => (b.confidence_avg ?? 0) - (a.confidence_avg ?? 0));
if (TARGET_FILES.length > 0) {
log(`LH_APPLIER_FILES set — constrained to ${TARGET_FILES.length} target file(s): ${TARGET_FILES.join(", ")}`);
}
log(`${eligible.length} pass confidence gate + deny-list${TARGET_FILES.length > 0 ? " + target filter" : ""}`);
log(`taking top ${Math.min(MAX_FILES, eligible.length)} by confidence`);
// Establish pre-run warning baseline so post-patch cargo check can
// reject patches that keep the build green but add new warnings.
// This gate exists because 96b46cd landed unused imports through the
// green-only gate — compiled fine, left a permanent dead-code warning.
log(`baseline cargo check (warning count)...`);
let baselineWarnings = (await cargoCheck()).warnings;
log(`baseline warnings = ${baselineWarnings}`);
let committedFiles = 0;
let revertedFiles = 0;
let warningReverts = 0;
let rationaleReverts = 0;
for (const r of eligible.slice(0, MAX_FILES)) {
log(`${r.file} (conf_avg=${r.confidence_avg} tier=${r.gradient_tier})`);
const full = `${REPO}/${r.file}`;
if (!existsSync(full)) { log(` skip — file not found on disk`); continue; }
const source = await readFile(full, "utf8");
const patches = await requestPatches(r.file, source, r.suggestions_preview ?? "");
if (patches.length === 0) {
log(` no patches produced`);
await auditLog({ action: "no_patches", file: r.file, reviewer_model: r.accepted_model });
continue;
}
log(` ${patches.length} candidate patches`);
const { applied, rejected } = await applyPatches(r.file, patches);
log(` applied ${applied}, rejected ${rejected.length}`);
for (const rj of rejected) log(` ✗ ${rj.reason}`);
if (applied === 0) {
await auditLog({ action: "all_rejected", file: r.file, rejected: rejected.map(x => x.reason) });
continue;
}
log(` running cargo check...`);
const { green, warnings } = await cargoCheck();
if (!green) {
log(` ✗ build red — reverting ${r.file}`);
await revertFile(r.file);
revertedFiles++;
await auditLog({ action: "build_red_reverted", file: r.file, patches_applied: applied });
continue;
}
// New-warning gate. If a patch keeps the build green but adds a
// warning (unused import, dead_code after removed allow, etc), treat
// it the same as build red: revert. The cargo-green gate alone let
// 96b46cd land unused imports; this closes that door.
if (warnings > baselineWarnings) {
log(` ✗ warnings ${baselineWarnings} → ${warnings} (+${warnings - baselineWarnings}) — reverting ${r.file}`);
await revertFile(r.file);
warningReverts++;
await auditLog({
action: "warnings_increased_reverted",
file: r.file,
patches_applied: applied,
warnings_before: baselineWarnings,
warnings_after: warnings,
});
continue;
}
// Rationale-diff alignment heuristic. At least one non-stopword
// token from the applied patches' combined new_string must appear
// in the rationale, OR the rationale must name the file's module.
// This catches cases like "Add destructive SQL filter" rationale
// with a diff that only adds `use tracing`.
const applied_patches = patches.slice(0, applied);
const rationale_ok = applied_patches.some((p) => rationaleMatchesDiff(p));
if (!rationale_ok) {
log(` ✗ rationale doesn't name anything in diff — reverting ${r.file}`);
await revertFile(r.file);
rationaleReverts++;
await auditLog({
action: "rationale_mismatch_reverted",
file: r.file,
patches_applied: applied,
rationales: applied_patches.map((p) => p.rationale),
});
continue;
}
log(` ✓ build green (warnings ${warnings}/${baselineWarnings})`);
// Dry-run MUST revert the file after a successful check so the
// workspace doesn't accumulate unpushed changes across dry-runs.
// Previously this left modified files, silently polluting state.
if (!COMMIT) {
log(` (dry-run) would commit ${r.file} — reverting workspace change`);
await revertFile(r.file);
await auditLog({
action: "dry_run_would_commit",
file: r.file,
patches_applied: applied,
patches_rejected: rejected.length,
confidence_avg: r.confidence_avg,
gradient_tier: r.gradient_tier,
reviewer_model: r.accepted_model,
});
continue;
}
const ok = await gitCommit(r.file, applied_patches);
if (ok) {
committedFiles++;
// Advance the warning baseline so a later patch on a different
// file is compared against the new (committed) state, not the
// pre-run state — otherwise every subsequent patch trips the gate.
baselineWarnings = warnings;
await auditLog({
action: "committed",
file: r.file,
patches_applied: applied,
patches_rejected: rejected.length,
confidence_avg: r.confidence_avg,
gradient_tier: r.gradient_tier,
reviewer_model: r.accepted_model,
warnings_after: warnings,
});
}
}
log(`DONE · committed=${committedFiles} build_red=${revertedFiles} warning_revert=${warningReverts} rationale_revert=${rationaleReverts}`);
}
await main();
File diff suppressed because it is too large Load Diff
-71
View File
@@ -1,71 +0,0 @@
# Truth rules — file-backed policy
Phase 42 PRD: *"truth/ dir at repo root — rule files, versioned in git."*
This directory is the canonical home for TruthStore rules loaded from
disk. Each `*.toml` file holds a set of `TruthRule` records for one
task class. The truth crate's `load_from_dir(path)` walks this
directory, parses every `.toml` file, and registers the rules it finds.
## Structure
```
truth/
├── README.md ← this file
├── staffing.fill.toml ← rules for task_class="staffing.fill"
└── staffing.any.toml ← rules for task_class="staffing.any"
```
File naming is informational — `load_from_dir` respects whatever
`task_class` the rule declares internally, NOT the filename. Using
task-class-matching filenames is a convention for humans reading the
git tree.
## Rule shape
```toml
[[rule]]
id = "worker-active"
task_class = "staffing.fill"
description = "Worker must be active"
condition = { type = "FieldEquals", field = "worker.status", value = "active" }
action = { type = "Pass" }
```
`condition.type` is one of:
- `Always` — always true
- `FieldEquals { field, value }`
- `FieldMismatch { field, value }`
- `FieldEmpty { field }`
- `FieldGreater { field, threshold }`
- `FieldContainsAny { field, needles }`
`action.type` is one of:
- `Pass` — rule informational; no enforcement
- `Reject { message }` — short-circuit with error
- `Redact { fields }` — mutate the context, strip fields
- `Block { message }` — hard stop, alert
## Composition
The crate's `default_truth_store()` continues to register rules
**in code** for backward-compat. Operators can layer file-backed
rules ON TOP via `load_from_dir`:
```rust
let store = truth::default_truth_store();
let store = truth::load_from_dir(&store, "/home/profit/lakehouse/truth")?;
```
File-loaded rules are additive — they do NOT replace in-code rules.
This lets the staffing team tune rules at the file level (edit a
threshold, add a new `FieldContainsAny` blocklist) without waiting
for a code deploy.
## Explicit non-goals
- **No hot reload** — per Phase 42 PRD ("Truth reload is explicit
in this phase"). Operators bounce the gateway or POST `/v1/context`
refresh endpoint (future) to pick up changes.
- **No inheritance** — each file stands alone; rule IDs must be unique
across all files. Duplicate-ID detection is a load-time error.
-20
View File
@@ -1,20 +0,0 @@
# Phase 42 — staffing.any task class rules (file-backed).
#
# Rules that apply across ALL staffing task classes (fill, rescue,
# sms_draft, etc). The router registers them once and evaluates them
# on every staffing.* call.
[[rule]]
id = "any.no-destructive-sql-in-context"
task_class = "staffing.any"
description = "Reject staffing calls whose SQL context contains destructive verbs"
action = { type = "Reject", message = "destructive SQL rejected by staffing.any gate" }
[rule.condition]
type = "FieldContainsAny"
field = "sql_context"
needles = ["drop table", "truncate", "delete from", "drop schema", "drop database"]
# Additional staffing.any rules (e.g. PII scrubbing, rate limits) can be
# layered here; see the in-code staffing_rules() for the pii-redact rule
# that ships by default.

Some files were not shown because too many files have changed in this diff Show More