Compare commits

...
20 Commits
Author SHA1 Message Date
rootandClaude Opus 4.7 eb4308d8fd real_006 diagnosis: Q43 leak is cross-city, not cross-role
Traced w-279's path through the substrate. The leak source is Q49
(Packers in Indianapolis IN for Midway Distribution), NOT Q18 as
the initial reading suspected.

Q49 recorded w-279 with role=Packers, client=Midway Distribution,
city=Indianapolis. Q43 (Packer in Chicago IL for Midway Distribution)
ran later. roleEqual("Packer","Packers") → both normalize to
"packer" → role gate passes (correctly, by design — they ARE the
same role under plural-strip). Cosine distance between Q49's
recorded query and Q43's query is small enough to fit inside the
0.20 inject threshold because role + client + count + time-token
dominate the embedding (only the city and singular/plural noun
differ). Inject fires, w-279 surfaces at Q43's warm top-1 in
Chicago, judge correctly rates 2/5 — wrong city.

The role gate IS working. What's missing is a CITY gate. Real_002's
fix targeted cross-role bleed (Forklift → CNC). real_006 surfaced
cross-city bleed within same role + same client — a hole prior
tests structurally couldn't reach because they all sourced from
rows 0-9 where no such pair existed.

Concrete fix surface documented (1 new field, 2 gate checks, 1
regex, ~5 tests). Half a session of work, same shape as real_002.
Not implementing tonight — diagnosis only.

The 18 unit-level role-gate tests still pass, confirming the gate
is doing what it was specified to do. The bug is a missing
specification, not a broken implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 05:13:19 -05:00
rootandClaude Opus 4.7 95f155b017 real_006: distribution-shift test on rows 10-59 of fill_events
Methodology fix: gen_real_queries.go gains -offset N flag. Every prior
real_NNN test sourced queries from rows 0-9 of fill_events.parquet
(default -limit 10), so the substrate's published "8/10 cold-pass top-1
= judge-best" was measured on a memorized slice, not held-out data.

real_006 samples 50 fresh rows (offset 10, never seen by the workers
or ethereal_workers corpora). Same harness, same local qwen2.5:latest
judge, same K=10. ~14 min wall total. Local-only, no cloud calls.

Headline findings:

- Cold-pass top-1 = judge-best (rank match): 41/50 (82%) vs real_001's
  8/10 (80%) — substrate generalizes at rank level.
- Strict (rating ≥ 2): 34/50 (68%) — 12-point drop from real_001's
  80%. ~7 of 41 "no-discovery" queries had cold top-1 the judge rated
  1; the corpus has gaps for some role-city combos in the v3 slice.
- Verbatim lift: 9/9 discoveries → warm top-1 (clean, matches real_001 2/2)
- Paraphrase recovery: 6/9 → top-1, 9/9 any-rank
- Quality regressed: 3/50 — Q43 is the structural one

Q43 (Packer at Midway Distribution / Chicago IL) regressed from
rating 5 to rating 2 on warm pass with `warm_boosted_count=0` and
`playbook_recorded=false`. Q18 (Shipping Clerks at the same client+city)
recorded a playbook entry. The regression suggests Q18's recording
leaked into Q43 via the warm-pass playbook corpus retrieval surface
even though the role gate from real_002 should have blocked it.
Three possible paths: extractor failed on one query, gate fires on
boost path but not Shape B inject, or cosine drift puts the recorded
worker close enough to Q43's embedding that warm-pass retrieval picks
it up directly. Diagnosis is the next move.

Three same-(client, city) clusters tested:
- Heritage Foods Gary IN × 3 distinct roles: clean, distinct workers
- Riverfront Steel Columbus OH × 4: cosine-level confusion (Q9/Q25
  surface same worker w-281 for Assemblers vs Quality Techs at cold-
  pass), but no playbook bleed
- Midway Distribution Chicago IL × 3: Q43 regression as above

What this confirms: substrate works on the fresh distribution at the
rank level, verbatim lift is real, paraphrase recovery is real.

What this falsifies: real_002's role-gate fix is not structurally
airtight. The bleed pattern can still fire under conditions the
prior tests didn't reach.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 04:54:03 -05:00
rootandClaude Opus 4.7 0e530f4436 drift fix: validatord in start_go_stack + parity refresh
Two anchor-vs-reality drifts found during /read-mem audit:

1. start_go_stack.sh never started validatord :3221, even though
   it shipped 2026-05-02 (f9e7241) and STATE_OF_PLAY claims it as
   part of the persistent stack. Cold-boot quietly omitted it,
   leaving /v1/iterate unreachable on the persistent gateway.
   Fix: factored chatd's conditional-start block into a start_shared
   helper, called for both chatd :3220 and validatord :3221. Same
   shared-with-smokes posture as chatd (no S3 / JSONL-only state,
   no temp-toml override needed).

2. STATE_OF_PLAY header claimed 3 parity probes / 32 assertions.
   Reality is 6 probes / 38 assertions since subject_audit landed
   in 262a77a (2026-05-03). Header refreshed; cross-references
   the three runtime-divergence classes documented at
   lakehouse/STATE_OF_PLAY.md lines 36-39.

Parity reports regenerated as verification artifact (all 6 still
green: 8+12+2+4+1+6). Same pattern as c0a55b1.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 03:27:47 -05:00
root c0a55b1182 parity reports: regenerated 2026-05-03 morning verification
All 6 probes re-run post-restart for today's verification:
  validator(6/6) + extract_json(12/12) + session_log(4/4) +
  materializer(2/2) + embed(8/8) + subject_audit(6/6) = 38/38
2026-05-03 05:27:14 -05:00
rootandClaude Opus 4.7 df2a9d1f77 catalogd: BiometricCollection on Go SubjectManifest reader
Mirrors lakehouse f1fa6e4 (Phase 1.6 Gate 3a). Adds:

  type BiometricCollection struct {
      DataPath           string
      TemplateHash       string
      CollectedAt        time.Time
      ConsentVersionHash string          (omitempty)
      Classifications    json.RawMessage (omitempty)
  }

SubjectManifest gains *BiometricCollection biometric_collection,omitempty
field so manifests written by the Rust biometric upload endpoint
round-trip through the Go reader without losing the new collection
record.

Cross-runtime parity probe (subject_audit_parity.sh) still 6/6
byte-identical after the field addition — the omitempty pointer means
manifests without a collection record produce identical bytes to
pre-Gate-3a manifests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 04:55:49 -05:00
rootandClaude Opus 4.7 857ca4c971 catalogd: HTML-safe escape fix + decisions tracker entry
Per 2026-05-03 step_7_8_retention_and_parity scrum (opus WARN on
parity_subject_audit.rs:canonical_json):

Go's json.Marshal HTML-escapes < > & to < > & by
default. Rust's serde_json::to_vec keeps them literal. Any audit
row with these chars in any string field would silently produce
different canonical bytes across runtimes → broken HMAC chain.
Latent because no production audit field has carried <>& yet, but
realistic for purpose strings ("error & retry") or trace_id values
("<HTTP-Request-Id>").

Fix: marshalNoEscapeHTML helper wraps json.Encoder.SetEscapeHTML(false)
+ trims trailing newline. Routed through writeCanonical for both
keys and scalar values.

Regression test: TestVerifyChain_HtmlChars_NotEscaped (purpose has &,
trace_id has <>) asserts the canonical bytes contain literal chars,
not escape sequences.

11 unit tests pass including the new one; parity probe still 6/6
byte-identical against live production audit logs.

Decisions tracker: added 2026-05-03 entry for SUBJECT_MANIFESTS_ON_CATALOGD
Steps 1-8 closure + 6th cross-runtime parity probe (was 5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 04:29:53 -05:00
rootandClaude Opus 4.7 262a77a52a subject-audit parity (Step 8) — Go reader + cross-runtime probe
Per /home/profit/lakehouse/docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §5 Step 8.

Go side reads SubjectManifest + verifies HMAC chain on per-subject
audit JSONL files using IDENTICAL canonical-JSON + HMAC-SHA256 algorithm
to crates/catalogd/src/subject_audit.rs. A Rust-written chain now
verifies under Go and vice versa.

Files:
  - internal/catalogd/subject.go
      SubjectManifest, SubjectAuditRow, AuditAccessor, AuditLogEntry
      LoadSubjectManifest, LoadKeyFile (32-byte minimum, matches Rust)
      ReadAuditLog, VerifyChain
      canonicalRowBytesFromRaw (production), canonicalRowBytesFromStruct (tests)
      computeRowHMAC, CanonicalAndHmac (parity helper)
  - internal/catalogd/subject_test.go (10 unit tests)
  - scripts/cutover/parity/subject_audit_helper/main.go
      CLI helper mirroring crates/catalogd/src/bin/parity_subject_audit.rs
  - scripts/cutover/parity/subject_audit_parity.sh
      Two-phase probe: known-answer + every real audit log

Two real bugs caught + fixed by the probe authoring loop:

1. omitempty on AuditAccessor.TraceID stripped the field when empty,
   producing different canonical bytes than Rust (which always writes
   the field). Removed omitempty. Rust + Go now produce identical
   bytes for rows with trace_id="" (the common production case).

2. time.RFC3339Nano strips trailing zeros from nanoseconds, producing
   "...46143921" where Rust's chrono AutoSi produces "...461439210".
   Hashing through the parsed-then-re-marshaled struct breaks the
   chain on any row whose nanos end in 0. Fixed by canonicalizing
   from the RAW LINE BYTES (preserves the original timestamp string
   byte-for-byte). Test TestVerifyChain_RawBytesPreserveTimePrecision
   regression-locks this with a hand-crafted nanos=461439210 row.

Live verification (6 / 6 byte-identical assertions):
  - Phase 1 known-answer: canonical bytes (266) + HMAC match
  - Phase 2 real logs: WORKER-1..5 audit JSONL all verify under both
    runtimes with identical (count, tip, verified, error) output

Report: reports/cutover/gauntlet_2026-05-02/parity/subject_audit_parity.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 04:17:15 -05:00
rootandClaude Opus 4.7 22c0b42e96 config: mark unwired ModelsConfig tier fields as scaffolding
Surfaced during today's local-only audit. The ModelsConfig struct
parses 11 tier fields from lakehouse.toml (LocalFast/LocalEmbed/
LocalJudge/LocalReview/CloudJudge/CloudReview/CloudStrong/
FrontierReview/FrontierArch/FrontierStrong/FrontierFree) and
exposes Resolve(tier) → model. As of 2026-05-03, NO code calls
Resolve(). Operators setting these in lakehouse.toml see no runtime
effect — silent config drift.

Not removing the fields (would silently swallow operator's existing
TOML and make it harder to wire later). Adding a clear UNWIRED
warning comment instead.

WeakModels IS live (consumed by internal/workflow/modes.go +
internal/matrix/downgrade.go) — split out with its own comment so
it's not lumped with the dead fields.

If a future commit wires up Resolve() consumers, replace the
UNWIRED comment with the consumer reference.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 02:54:10 -05:00
rootandClaude Opus 4.7 5d3996b51d STATE_OF_PLAY: Rust is not maintenance-only as of 2026-05-02
Frames the Rust system accurately — it's receiving parity work +
infrastructure (Lance gauntlet, sidecar drop, observability parity),
not just security fixes. Points readers at lakehouse/STATE_OF_PLAY.md
+ docs/ARCHITECTURE_COMPARISON.md for the cross-runtime view.

Also commits today's parity probe report regenerations (5/5 still
32/32 post-Lance work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:24:14 -05:00
rootandClaude Opus 4.7 916923440a docs(comparison): close Lance backend deferral, reframe as Lance-vs-Parquet+HNSW
Lance went from "deferred until corpus exceeds 5M rows" to verified
production-ready at 10M in a single wave (lakehouse repo commits
7594725 + 5d30b3d). Captures the 4-pack (sanitizer + tests + smoke +
bench) + the root-cause fix (auto-build doc_id btree in migrate handler).

Reframes the strategic question: HNSW at 10M doesn't fit RAM, so the
real choice is Lance vs Parquet+HNSW-with-spilling, deferred until we
have a workload where the Parquet path is the bottleneck.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 22:10:46 -05:00
rootandClaude Opus 4.7 b314ed1c94 parity: /v1/embed cross-runtime probe (5th probe, 8/8 cosine match)
Today's sidecar drop (lakehouse ba928b1) changed Rust's embed
transport from gateway → sidecar → Ollama (2 hops) to gateway →
Ollama directly. Go's embedd has always been direct. A drift here
would mean: same query, different vector → different HNSW top-K →
different staffing recommendations. This probe is the regression
gate for that surface.

Fixtures cover staffing-domain shapes (forklift, welder, OSHA,
dental, CNC) plus stress shapes (unicode "Café résumé ⭐ 你好",
single char "x", 200-word long fixture).

Match metric: cosine similarity ≥ 0.99999. Byte-equal isn't
expected — Go round-trips through []float32 internally while Rust
stays at Vec<f64>, so JSON serialization introduces small float
drift. What matters operationally is vector direction (HNSW uses
cosine distance), and both runtimes preserve it when calling the
same Ollama with the same model.

Result: **8/8 fixtures match** including the long + unicode cases.
Sidecar drop didn't disturb the embed surface. The probe also
forces both endpoints to use `nomic-embed-text` so the v1-vs-v2-moe
default difference doesn't pollute the comparison.

5th cross-runtime parity probe joining the family:
  - validator_parity (6/6)
  - extract_json_parity (12/12)
  - session_log_parity (4/4)
  - materializer_parity (2/2)
  - embed_parity (8/8) — this commit

Cumulative: 32/32 parity assertions across 5 probes covering
HTTP shape (validator, embed), CLI output (materializer), unit
behavior (extract_json), and persisted shape (session_log).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 06:28:40 -05:00
rootandClaude Opus 4.7 a21a34b057 docs: close 2 cross-runtime parity gaps + document unified log
Companion to lakehouse 98b6647. Architecture comparison decisions
tracker now captures:

  - Go validatord direct header read (fixes 6847bbc): closes the
    case where Langfuse-off middleware passthrough silently dropped
    forwarded X-Lakehouse-Trace-Id
  - Rust IterateResponse trace_id echo (fixes 98b6647): closes the
    asymmetry where Go's response carried the join key and Rust's
    didn't
  - Unified longitudinal log demonstrated end-to-end: both daemons
    co-writing /tmp/lakehouse-validator/sessions.jsonl, distinct
    daemon tags, one DuckDB query covers both

24/24 parity assertions (validator 6/6, extract_json 12/12,
session_log 4/4, materializer 2/2) hold against live :3100 + :4110.
Both runtimes deployed with today's full stack.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 06:25:21 -05:00
rootandClaude Opus 4.7 6847bbc180 validatord: honor X-Lakehouse-Trace-Id even when Langfuse is off
Surfaced by the 2026-05-02 cross-runtime test: when a caller
forwarded X-Lakehouse-Trace-Id but the langfuse middleware was a
passthrough (no Langfuse env), the header was never read — Go minted
a fallback id, breaking cross-daemon parent-trace linkage.

The middleware only honored the header when its lf client was
non-nil. With LANGFUSE_URL unset on the persistent stack, every
inbound iterate request lost the parent linkage.

Fix: validatord's iterate handler reads the header DIRECTLY (matches
Rust's iterate.rs pattern) before falling through to the ctx value
+ fallback id. Now Go behavior matches Rust regardless of Langfuse
configuration.

Resolution order is:
  1. req.TraceID (caller put it in the JSON body)
  2. X-Lakehouse-Trace-Id header (read directly here)
  3. context value from langfuse middleware (when configured)
  4. fallback to a locally-minted time-ordered hex id

Verified end-to-end:
  curl -H 'X-Lakehouse-Trace-Id: go-cmp-fixed' POST /v1/iterate
  → response.trace_id = "go-cmp-fixed" ✓
  → sessions.jsonl row session_id = "go-cmp-fixed" ✓

Pre-fix (this commit's parent ran from /tmp/val-fresh3 binary):
  same call → trace_id minted as 18abbb5a008061b7-008061e9
  (header silently ignored)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 06:16:25 -05:00
rootandClaude Opus 4.7 1263720497 validatord: always populate session_id (fallback when Langfuse off)
Surfaced during the 2026-05-02 deploy + reality wave: the persistent
Go stack runs without LANGFUSE_URL/PUBLIC_KEY/SECRET_KEY env, so
shared.langfuseMiddleware operates as a passthrough — never minting
a trace id, never stashing it on the request context. Result:
session_id was empty on every JSONL row, breaking correlation across
the longitudinal log + replay_runs.jsonl + future Langfuse traces.

The fix: validatord falls back to a locally-generated time-ordered
hex id when both the X-Lakehouse-Trace-Id header AND the middleware
context are empty. Same shape Langfuse accepts, so a future deploy
that turns Langfuse on doesn't break correlation — already-emitted
session_ids stay valid as Langfuse trace ids.

Verified post-deploy by driving 9 /v1/iterate sessions through the
persistent stack at :4110:
  - 6 accepted on iter 0 (qwen2.5:latest first-shot 75%)
  - 2 max_iter_exhausted (no_json on prose-y prompts)
  - 1 infra_error (chatd cold-start probe timed out at 5s)

Latest row's session_id: "18abbabdc2306a83-c2306aa9" (was: "")

Probe re-runs (validator_parity, session_log_parity) included as
post-deploy artifacts; both 6/6 + 4/4 with the freshly-restarted
persistent gateway+validatord binaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 06:03:43 -05:00
rootandClaude Opus 4.7 fa4e1b4e16 parity: session_log probe + Rust observability parity recorded
Companion to lakehouse commit 57bde63 (Rust gateway gains
trace-id propagation + coordinator session JSONL). The
cross-runtime parity probe is the regression gate that prevents
silent schema drift between the two runtimes.

scripts/cutover/parity/session_log_parity.sh:
  - 4 fixtures (accepted_grounded, max_iter_exhausted, infra_error,
    unicode_in_prompt) feed identical input to both helpers
  - jq -e validity gate + non-trivial-equal guard prevents the
    "both sides fail identically → spurious match" failure mode
    (caught one IFS='||' bug during initial authoring — recorded
    in the script comment)
  - normalize() strips timestamp + daemon (legitimate per-producer
    differences); everything else must be byte-equal
  - Result: 4/4 fixtures match, including unicode

scripts/cutover/parity/session_log_helper/main.go:
  - Tiny stdin/stdout Go helper that round-trips a fixture
    through validator.SessionRecord serde
  - Counterpart to crates/gateway/src/bin/parity_session_log.rs

docs/ARCHITECTURE_COMPARISON.md decisions tracker:
  - "Rust observability parity" row added (DONE 2026-05-02)
  - Cross-runtime probe documented as reusable gate

STATE_OF_PLAY refreshed.

Both observability pieces (trace-id propagation, session JSONL)
now exist on both runtimes. Operators who point Rust gateway and
Go validatord at the same session-log path get a unified
longitudinal stream queryable via DuckDB.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 05:39:49 -05:00
rootandClaude Opus 4.7 1a3a82aedb validatord: coordinator session JSONL for offline analysis (B follow-up)
Closes the second half of J's 2026-05-02 multi-call observability
concern. Trace-id propagation (commit d6d2fdf) gave us the *live*
view in Langfuse; this gives us the *longitudinal* view for ad-hoc
DuckDB queries over thousands of sessions:

  "show me every session where the model produced a real candidate
   without ever needing a retry"
  "find sessions where validation rejected three times in a row"
  "first-shot success rate per model — did we feed it enough corpus?"

## What's in

internal/validator/session_log.go:
  - SessionRecord type (schema=session.iterate.v1)
  - SessionLogger writer — mutex-guarded append, best-effort posture,
    nil-safe (NewSessionLogger("") = nil = no-op on Append)
  - BuildSessionRecord helper — assembles a row from any
    iterate response/failure/infra-error combination, callable from
    other daemons that wrap iterate (cross-daemon shared schema)
  - 7 unit tests including concurrent-append safety + the three
    code paths (success / max_iter_exhausted / infra_error)

cmd/validatord/main.go:
  - handlers.sessionLog field + wiring from cfg.Validatord.SessionLogPath
  - Iterate handler: build + append a SessionRecord on every call
  - rosterCheckFor("fill") closure stamps grounded_in_roster — the
    load-bearing forensic property J flagged ("we can never
    hallucinate available staff members to contracts")

internal/shared/config.go + lakehouse.toml:
  - [validatord].session_log_path field; empty = disabled
  - Production: /var/lib/lakehouse/validator/sessions.jsonl

scripts/validatord_smoke.sh:
  - Adds a probe verifying validatord announces session log path on
    startup. Smoke is now 6/6 (was 5/5).

docs/SESSION_LOG.md:
  - Schema reference + 5 worked DuckDB query examples including the
    "alarm" query (sessions where grounded_in_roster=false on an
    accepted fill — should always be empty; if not, something is
    bypassing FillValidator).

## What this is NOT

This is NOT a duplicate of replay_runs.jsonl. They're siblings:
  - replay_runs.jsonl: replay tool's per-task retrieval+model output
  - sessions.jsonl: validatord's per-iterate full retry chain +
    grounded-in-roster verdict

A single coordinator session can produce rows in both streams; the
session_id (= Langfuse trace_id) is the join key.

## Layered observability now in place

  Live view:  Langfuse trace tree (X-Lakehouse-Trace-Id propagation)
              `iterate.attempt[N]` spans with prompt/raw/verdict
  Offline:    coordinator_sessions.jsonl (this commit)
              DuckDB-queryable; longitudinal forensics
  Hard gate:  FillValidator + WorkerLookup (existing)
              phantom IDs structurally rejected, never reach
              session log's grounded_in_roster=true bucket

Per the architecture invariant in STATE_OF_PLAY's DO NOT RELITIGATE
section — these layers are wired; future work targets the data, not
the wiring.

## Verification

- internal/validator: 7 new tests (session_log_test.go) — all PASS
- cmd/validatord: 3 new integration tests covering the success,
  failure, and grounded=false paths — all PASS
- validatord_smoke.sh: 6/6 PASS through gateway :3110
- Full go test ./... green across 33 packages

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 05:22:09 -05:00
rootandClaude Opus 4.7 d6d2fdf81f trace-id propagation through /v1/iterate (multi-call observability)
Closes J's 2026-05-02 multi-call observability gap: a single
/v1/iterate session with N retries used to surface in Langfuse as
N+1 disconnected traces (one per /v1/chat hop + one for the iterate
request itself), with no parent/child linkage. Operators couldn't
scroll the retry chain in one trace tree to spot where grounding
failed.

## Wire-level change

- New header constant `shared.TraceIDHeader = "X-Lakehouse-Trace-Id"`
- `langfuseMiddleware` honors the header on inbound requests: if
  set, reuses that trace id instead of minting a new one. Stashes
  the trace id on the request context so handlers can attach
  application-level child spans.
- `validatord.chatCaller` forwards the header to chatd. Every chat
  hop in an iterate session lands as a child of the parent trace.

## Application-level spans

- `validator.IterateConfig` gains `Tracer` (optional callback).
  When wired, each iteration attempt emits one Langfuse span
  via `validator.AttemptSpan`:
    Name: iterate.attempt[N]
    Input: { iteration, model, provider, prompt }
    Output: { verdict, raw, error }
    Level: WARNING when verdict != accepted
- `validatord.iterTracer` is the production hook — bridges
  `validator.Tracer` → `langfuse.Client.Span`.
- `IterateRequest`/`IterateResponse`/`IterateFailure` gain
  `TraceID`; each `IterateAttempt` gains `SpanID`. The /v1/iterate
  caller can pivot from the JSON response straight into the
  Langfuse trace tree.

## What an operator sees post-cutover

  GET /v1/iterate {kind=fill, prompt=...} → Trace TR-1
    ├─ http.request span (from middleware)
    ├─ iterate.attempt[0] span (validator.Iterate emit)
    │     input: prompt+model
    │     output: { verdict: validation_failed, error: ..., raw }
    ├─ chatd /v1/chat call (X-Lakehouse-Trace-Id: TR-1)
    │     ├─ http.request span (chatd middleware)
    │     └─ chatd-internal spans (existing)
    ├─ iterate.attempt[1] span
    └─ ...

All in one Langfuse trace tree, not N+1 separate traces.

## Hallucinated-worker safety net is unchanged

The /v1/iterate flow's hard correctness gate is still
FillValidator + WorkerLookup. Phantom candidate IDs raise
ValidationError::Consistency which 422s and forces the iteration
loop to retry. The trace-id propagation is the OBSERVABILITY layer
on top — it makes the existing safety net's outcomes visible per-call,
not a replacement for it.

## Verification

- internal/validator: 4 new tests
  - TestIterate_TracerEmitsSpanPerAttempt — span/attempt count + SpanID
  - TestIterate_NoTraceIDSkipsTracer — no orphan spans without trace_id
  - TestIterate_ChatCallerReceivesTraceID — propagation contract
  - (existing iterate tests updated for new ChatCaller signature)
- internal/shared: 1 new test
  - TestLangfuseMiddleware_HonorsTraceIDHeader — cross-service linkage
- cmd/validatord: existing HTTP tests still PASS via the dual-shape
  UnmarshalJSON contract.
- validatord_smoke.sh: 5/5 PASS through gateway :3110 (unchanged).
- Full go test ./... green across 33 packages.

## Architecture invariant added

STATE_OF_PLAY "DO NOT RELITIGATE" gains a paragraph documenting
the X-Lakehouse-Trace-Id header contract + the iterate.attempt[N]
span emission. Future-Claude won't re-propose "wire trace-id
propagation" — the header IS the wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 05:13:18 -05:00
rootandClaude Opus 4.7 afdeca80d9 docs: record Python sidecar drop in architecture comparison
Companion to lakehouse commit ba928b1 (aibridge: drop Python sidecar
from hot path; AiClient → direct Ollama).

ARCHITECTURE_COMPARISON.md:
  - Decisions tracker: "Drop Python sidecar" moves _open_ → DONE
  - "Cross-cutting abstracts to address" item #3 marked complete
  - "Python dependency (the load-bearing axis)" section reframed:
    pre/post-2026-05-02 diagram, lab UI / pipeline_lab clarified
    as dev-only Python (not on runtime hot path)
  - Change log: new entry

STATE_OF_PLAY: timestamp refresh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 05:00:51 -05:00
rootandClaude Opus 4.7 7d6636b33e validator: align ValidationError JSON to Rust serde shape (6/6 parity)
Closes the 2026-05-02 parity finding: validator_parity probe found
5/6 body shapes diverging because Go emitted {"Kind":"...","Field":"...","Reason":"..."}
while Rust emits the externally-tagged-enum {"Schema":{"field":"...","reason":"..."}}.
A caller parsing the error envelope would break silently in cutover.

## Changes

internal/validator/types.go:
- Custom MarshalJSON emits the Rust shape:
    Schema:       {"Schema":      {"field":"x","reason":"y"}}
    Completeness: {"Completeness":{"reason":"y"}}
    Consistency:  {"Consistency": {"reason":"y"}}
    Policy:       {"Policy":      {"reason":"y"}}
- Custom UnmarshalJSON accepts BOTH the new Rust shape AND the legacy
  flat shape (migration safety for any persisted error rows).
- Unknown variants (e.g. a future Rust addition Go hasn't learned)
  surface as an Unmarshal error, not a silent default.

internal/validator/types_test.go:
- 4 pinning tests anchor the wire format. Failing them = wire-format
  drift; the parity probe is the secondary line of defense.

scripts/validatord_smoke.sh:
- Updated probes to read the new variant-name shape (jq keys[0],
  .Schema.field) instead of legacy .Kind/.Field.

## Verification

- internal/validator unit tests: PASS (4 new + all existing).
- cmd/validatord HTTP tests: PASS (UnmarshalJSON falls through to flat
  shape so existing tests reading ValidationError still work).
- validatord_smoke.sh: 5/5 PASS through gateway :3110.
- validator parity probe re-run: **6/6 match** (was 1/6).

## Pattern

Per architecture_comparison's "use the dual-implementation as a
measurement instrument" thesis: a parity probe surfaced this gap;
50 LOC of MarshalJSON closed it; 4 pinning tests prevent regression;
the probe is the longitudinal gate. Cutover-friendly direction (Go
matches Rust) chosen because Rust is the existing production
contract.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:49:28 -05:00
rootandClaude Opus 4.7 b0c8a3f227 parity probes: materializer + extract_json (caught + fixed real bug)
Two new cross-runtime parity probes joining the validator probe from
the gauntlet wave. Pattern: feed identical input through Rust and Go;
diff outputs. Each probe surfaced a different signal.

## Materializer parity probe
scripts/cutover/parity/materializer_parity.sh runs Bun + Go
materializer against an identical synthetic data/_kb/ root, diffs the
resulting evidence/ JSONL byte-equivalent (modulo provenance.recorded_at).

**First run: 0/2 match.** Real finding: Go's Provenance.LineOffset
had `json:"line_offset,omitempty"` which strips the field when value
is 0. Line offset 0 is the FIRST ROW of every source file — a real
semantic value, not absent. Bun side always emits it.

Fix: drop `omitempty` on Provenance.LineOffset. Updated comment
explaining why.

**Re-run: 2/2 match.** On-wire JSON parity holds.

## extract_json parity probe
scripts/cutover/parity/extract_json_parity.sh feeds 12 fixture
strings through both runtimes' extract_json:
  - fenced ```json``` blocks
  - unfenced ``` blocks
  - bare braces with prose around
  - first-balanced-of-many
  - nested objects
  - unicode in string values
  - escaped quotes
  - empty object
  - top-level array (both return first inner object)
  - no JSON
  - depth-balanced but invalid syntax
  - trailing garbage

Substrate gate: cargo test -p gateway extract_json PASS before probe.

**Result: 12/12 match.** Algorithms genuinely equivalent.

## scripts/cutover/parity/extract_json_helper/main.go
Tiny Go binary that reads stdin, calls validator.ExtractJSON, prints
{matched, value} JSON. Counterpart to the Rust parity_extract_json
binary in golangLAKEHOUSE's sibling lakehouse repo (separate commit).

## Pattern crystallized
Every cross-runtime port should land with a parity probe. Three
probes now exist:
  - validator (5/6 wire-format gap captured 2026-05-02)
  - materializer (caught + fixed real bug 2026-05-02)
  - extract_json (12/12 match 2026-05-02)

The instrument is reusable — each new shared HTTP/CLI surface gets
a probe row added.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 04:43:54 -05:00
39 changed files with 3877 additions and 213 deletions
+6 -4
View File
@@ -1,9 +1,9 @@
# STATE OF PLAY — Lakehouse-Go
**Last verified:** 2026-05-02 ~05:00 CDT
**Verified by:** **production-readiness gauntlet** — 21/21 smoke chain green in ~60s, per-component scrum across 4 bundles (no convergent findings, no real bugs), cross-runtime validator parity probe (6/6 status match, 5/6 body shape divergence captured as known gap). Disposition: `reports/cutover/gauntlet_2026-05-02/disposition.md`.
**Last verified:** 2026-05-02 ~11:30 CDT
**Verified by:** **production-readiness gauntlet** — 21/21 smoke chain green, per-component scrum across 4 bundles, **6 cross-runtime parity probes all green: 38/38 byte-identical assertions** (validator 6/6 after wire-format alignment, materializer 2/2 after omitempty fix, extract_json 12/12, session_log 4/4, embed 8/8 cosine, subject_audit 6/6 after the three runtime-divergence-class fixes documented at `lakehouse/STATE_OF_PLAY.md` lines 36-39). All findings surfaced by the parity probes have been actioned. Disposition: `reports/cutover/gauntlet_2026-05-02/disposition.md`. Subject-audit probe added 2026-05-03 (`262a77a`).
> **Read this FIRST.** When the user says "we're working on lakehouse," default to the Go rewrite (this repo); the Rust legacy at `/home/profit/lakehouse/` is maintenance-only. If memory contradicts this file, this file wins. Update it when something is verified working — not when a phase finishes.
> **Read this FIRST.** When the user says "we're working on lakehouse," default to the Go rewrite (this repo) but ASK if ambiguous — the Rust system at `/home/profit/lakehouse/` is also receiving real work as of 2026-05-02 (Lance backend hardening, observability parity, sidecar drop — see `lakehouse/STATE_OF_PLAY.md`). The cross-runtime decisions tracker at `docs/ARCHITECTURE_COMPARISON.md` is the single source of truth for what's open across both. If memory contradicts this file, this file wins. Update it when something is verified working — not when a phase finishes.
---
@@ -198,7 +198,7 @@ Verbatim verdicts at `reports/scrum/_evidence/2026-04-30/verdicts/`. Disposition
### Session frame (don't redo)
- The Rust legacy is **maintenance-only** until Go reaches feature parity. Don't propose ports of components already shipped here.
- The Rust system is **NOT maintenance-only** as of 2026-05-02 — it's receiving parity work + infrastructure (Lance gauntlet, sidecar drop, observability parity). Don't propose ports of components already shipped on the Go side, but do expect Rust commits to land in parallel for cross-runtime fabric.
- The matrix indexer **5/5 components** are shipped. Don't propose to "build the matrix indexer" — it's done.
- The 5-loop substrate's load-bearing gate is **PASSED**. v3 (`154a72e`) showed 6/6 paraphrase recovery via Shape B. Don't propose to "test if the playbook learns" — it does.
- **Shape B is the playbook stance now.** When `use_playbook=true`, both `ApplyPlaybookBoost` (re-rank in place) AND `InjectPlaybookMisses` (insert recorded answers not in regular top-K) run. Don't revert to boost-only; v2 proved that path can't recover paraphrase queries.
@@ -216,6 +216,8 @@ Verbatim verdicts at `reports/scrum/_evidence/2026-04-30/verdicts/`. Disposition
- **Langfuse Go-side client lives at `internal/langfuse/`** with best-effort fail-open posture. URL+creds from `/etc/lakehouse/langfuse.env`. Don't propose to "wire Langfuse on Go side" — it's wired; multi_coord_stress is the proof.
- **vectord's source-of-truth is `i.vectors`, NOT the coder/hnsw graph.** The `Index` struct holds a parallel `vectors map[string][]float32` updated on every successful Add/Delete; the graph is a derived, replaceable view. `safeGraphAdd`/`safeGraphDelete` wrap the library's panic-prone ops; `rebuildGraphLocked` reads from `i.vectors` (graph-state-independent). Don't propose to "drop the side map for memory" — it's the load-bearing piece that makes Add panic-recoverable past the small-index threshold (closes the multitier_100k 277884b 96-98% fail). The prior `i.ids` set was folded into `i.vectors` keys.
- **vectord saves are coalesced async, not synchronous.** `cmd/vectord/main.go` runs a per-index `saveTask` that single-flights through `Persistor.Save` — at most one in-flight + one pending. Add returns OK before the save completes; an Add-then-crash can lose ~1 save's worth of data, matching ADR-005's fail-open posture. Don't propose to "make saves synchronous for durability" — that re-introduces the lock-contention bottleneck (1-2.5s tail at conc=50, observed 2026-05-01) without fixing a real durability hole (in-memory state is the source of truth in flight).
- **`X-Lakehouse-Trace-Id` header propagates Langfuse parent traces across daemon boundaries.** When validatord's `/v1/iterate` calls chatd's `/v1/chat`, it forwards the header so chatd's middleware reuses the parent trace id instead of minting a new one. Each iteration attempt also emits a child span (`iterate.attempt[N]`) carrying the prompt, raw model output, and validator verdict. Result: an iterate session with N retries shows in Langfuse as ONE trace tree, not N+1 disconnected traces. Don't propose to "wire trace-id propagation" — it's wired; the test at `internal/shared/langfuse_middleware_test.go::TestLangfuseMiddleware_HonorsTraceIDHeader` is the proof. Closes J's 2026-05-02 multi-call observability concern.
- **Coordinator session JSONL exists at `[validatord].session_log_path`.** One row per `/v1/iterate` session, schema=`session.iterate.v1`, append-only. Captures session_id (= Langfuse trace_id), kind, model, iterations, attempts[], final_verdict, **grounded_in_roster** (the load-bearing forensic property — always true on accepted fills, false on phantom-worker artifacts that somehow slip past FillValidator). Empty path = disabled. DuckDB queries documented at `docs/SESSION_LOG.md`. Don't propose to "build offline session analysis" — it's built; the schema is locked at v1; rotate the file via logrotate when it gets large.
---
+174 -2
View File
@@ -34,6 +34,7 @@ import (
"github.com/go-chi/chi/v5"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/langfuse"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/shared"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/validator"
)
@@ -73,6 +74,15 @@ func main() {
DefaultMaxIterations: cfg.Validatord.DefaultMaxIterations,
DefaultMaxTokens: cfg.Validatord.DefaultMaxTokens,
},
// Same env loader the middleware uses — getting our own
// reference lets us emit child spans of the per-request trace.
// nil when Langfuse env isn't set; the iterate handler skips
// span emission gracefully in that case.
lf: shared.LoadLangfuseFromEnv(),
sessionLog: validator.NewSessionLogger(cfg.Validatord.SessionLogPath),
}
if h.sessionLog != nil {
slog.Info("validatord session log", "path", cfg.Validatord.SessionLogPath)
}
if err := shared.Run("validatord", cfg.Validatord.Bind, h.register, cfg.Auth); err != nil {
@@ -86,6 +96,16 @@ type handlers struct {
chatdURL string
chatClient *http.Client
iterCfg validator.IterateConfig
// lf is the Langfuse client (nil when unconfigured — best-effort
// posture matching the rest of the stack). Per-attempt iteration
// spans get emitted via lf when set. The HTTP middleware that
// creates the parent trace lives in shared.Run; this client lets
// validatord's handler emit application-level child spans.
lf *langfuse.Client
// sessionLog appends one SessionRecord per /v1/iterate session to
// `coordinator_sessions.jsonl` for offline DuckDB analysis. nil
// when the session log is unconfigured (best-effort posture).
sessionLog *validator.SessionLogger
}
func (h *handlers) register(r chi.Router) {
@@ -224,6 +244,30 @@ func (h *handlers) handleIterate(w http.ResponseWriter, r *http.Request) {
return
}
// Resolve the parent trace id, in priority order:
// 1. req.TraceID (caller put it in the JSON body)
// 2. X-Lakehouse-Trace-Id header (read DIRECTLY here so the path
// works even when Langfuse is off and the middleware is a
// passthrough — matches Rust's iterate handler shape)
// 3. context value set by the langfuse middleware (when Langfuse
// is configured)
// 4. fallback to a locally-minted time-ordered hex id
//
// Surfaced 2026-05-02 cross-runtime test: Go was minting fallback
// even when caller forwarded the header, because middleware
// passthrough mode never read it. Reading the header here closes
// the gap and makes Go behavior match Rust regardless of
// Langfuse configuration.
if req.TraceID == "" {
req.TraceID = r.Header.Get(shared.TraceIDHeader)
}
if req.TraceID == "" {
req.TraceID = shared.TraceIDFromCtx(r.Context())
}
if req.TraceID == "" {
req.TraceID = newFallbackTraceID()
}
chat := h.chatCaller()
validate := func(kind string, artifact map[string]any) (validator.Report, error) {
report, vErr, kindErr := h.runValidator(kind, artifact, req.Context)
@@ -239,7 +283,24 @@ func (h *handlers) handleIterate(w http.ResponseWriter, r *http.Request) {
return *report, nil
}
resp, fail, err := validator.Iterate(r.Context(), req, h.iterCfg, chat, validate)
// Wire per-attempt span emission when Langfuse is configured —
// each chat→validate cycle becomes a child span of the iterate
// trace, with prompt/raw/verdict captured for inspection.
cfg := h.iterCfg
if h.lf != nil {
cfg.Tracer = h.iterTracer(r.Context())
}
t0 := time.Now()
resp, fail, err := validator.Iterate(r.Context(), req, cfg, chat, validate)
durationMs := time.Since(t0).Milliseconds()
// Emit one session row regardless of success/failure/infra-error.
// Best-effort: a failing session log never blocks the response.
rosterCheck := h.rosterCheckFor(req.Kind)
rec := validator.BuildSessionRecord(req, resp, fail, err, rosterCheck, durationMs)
h.sessionLog.Append(rec)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
@@ -251,11 +312,64 @@ func (h *handlers) handleIterate(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// rosterCheckFor returns a closure that verifies an accepted artifact's
// candidate IDs all exist in the roster. Used by BuildSessionRecord to
// stamp `grounded_in_roster` on each session row — answers the "did
// the model produce real workers, not phantoms?" question at offline-
// query time.
//
// Only the `fill` kind has worker IDs to verify. Other kinds return
// nil (omits the field from the session record).
//
// Note: FillValidator already enforces this constraint on every
// successful iteration (phantom IDs raise ValidationError::Consistency).
// Stamping the bool here surfaces the verified-grounded property as
// queryable data, not relying on validator semantics being recalled
// during analysis. A future scrum may want to query "show me sessions
// where the model guessed real worker IDs without ever needing a
// retry" — that's a `iterations=1 AND grounded_in_roster=true` query.
func (h *handlers) rosterCheckFor(kind string) func(map[string]any) *bool {
if kind != "fill" {
return nil
}
return func(artifact map[string]any) *bool {
fills, ok := artifact["fills"].([]any)
if !ok {
f := false
return &f
}
for _, raw := range fills {
fill, ok := raw.(map[string]any)
if !ok {
f := false
return &f
}
id, _ := fill["candidate_id"].(string)
if id == "" {
f := false
return &f
}
if _, found := h.lookup.Find(id); !found {
f := false
return &f
}
}
t := true
return &t
}
}
// chatCaller wires the iteration loop to chatd via HTTP. Builds the
// chat.Request shape, posts to ${chatdURL}/chat, returns the content
// string (no choices wrapper — chatd's response is already flat).
//
// When traceID is non-empty, forwards it as the `X-Lakehouse-Trace-Id`
// header so chatd's middleware attaches its trace to the same
// Langfuse parent. Closes the multi-call observability gap: a
// /v1/iterate session shows in Langfuse as one trace with N child
// chat spans, instead of N+1 disconnected traces.
func (h *handlers) chatCaller() validator.ChatCaller {
return func(ctx context.Context, system, user, _, model string, temp *float64, maxTokens int) (string, error) {
return func(ctx context.Context, system, user, _, model string, temp *float64, maxTokens int, traceID string) (string, error) {
messages := make([]map[string]string, 0, 2)
if system != "" {
messages = append(messages, map[string]string{"role": "system", "content": system})
@@ -278,6 +392,9 @@ func (h *handlers) chatCaller() validator.ChatCaller {
return "", fmt.Errorf("build chat req: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if traceID != "" {
req.Header.Set(shared.TraceIDHeader, traceID)
}
resp, err := h.chatClient.Do(req)
if err != nil {
return "", fmt.Errorf("chat hop: %w", err)
@@ -297,6 +414,61 @@ func (h *handlers) chatCaller() validator.ChatCaller {
}
}
// iterTracer adapts validator.Tracer → langfuse.Client.Span. Each
// iteration attempt becomes one Langfuse span on the parent trace
// with input={prompt, model, provider}, output={raw, verdict}.
// Operators reading Langfuse can scroll the iterate session and see
// the full retry chain — which prompt produced which raw, which
// validator verdict landed, which retry the model recovered on.
//
// Returns the span id so it lands in IterateAttempt.SpanID — the
// /v1/iterate response carries that id back to the caller for log
// correlation.
//
// Errors are silent (best-effort posture per package langfuse). A
// dropped span never blocks the iterate loop.
func (h *handlers) iterTracer(ctx context.Context) validator.Tracer {
return func(s validator.AttemptSpan) string {
level := "DEFAULT"
if s.Verdict.Kind != "accepted" {
level = "WARNING"
}
return h.lf.Span(ctx, langfuse.SpanInput{
TraceID: s.TraceID,
Name: fmt.Sprintf("iterate.attempt[%d]", s.Iteration),
Input: map[string]any{
"iteration": s.Iteration,
"model": s.Model,
"provider": s.Provider,
"prompt": trim(s.Prompt, 4000),
},
Output: map[string]any{
"verdict": s.Verdict.Kind,
"error": s.Verdict.Error,
"raw": trim(s.Raw, 4000),
},
Metadata: map[string]any{
"verdict_error": s.Verdict.Error,
},
StartTime: s.StartTime,
EndTime: s.EndTime,
Level: level,
})
}
}
// newFallbackTraceID generates a time-ordered hex id used when no
// upstream trace id was forwarded AND the langfuse middleware didn't
// mint one (Langfuse unconfigured). Same shape Langfuse accepts so
// future Langfuse-enabled deployments don't break correlation. Avoids
// pulling in a uuid crate dep; nanosecond precision + a randomness
// suffix is unique enough at the rates iterate runs.
func newFallbackTraceID() string {
ns := time.Now().UTC().UnixNano()
rand := uint32(time.Now().UnixNano() % (1 << 32))
return fmt.Sprintf("%016x-%08x", ns, rand)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
+170 -4
View File
@@ -1,10 +1,13 @@
package main
import (
"bufio"
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
@@ -13,11 +16,47 @@ import (
"git.agentview.dev/profit/golangLAKEHOUSE/internal/validator"
)
// readSessionLog parses the JSONL session log into row maps. Used by
// the iterate-handler tests to verify per-session entries.
func readSessionLog(t *testing.T, path string) []map[string]any {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open session log: %v", err)
}
defer f.Close()
var out []map[string]any
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<16), 1<<24)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var row map[string]any
if err := json.Unmarshal(line, &row); err != nil {
t.Fatalf("parse session log line: %v", err)
}
out = append(out, row)
}
return out
}
// newTestRouter builds the validatord router with an explicit lookup
// + a fake chatd URL. Tests that exercise /iterate need a live mock
// chatd (constructed inline per-test).
func newTestRouter(lookup validator.WorkerLookup, chatdURL string) http.Handler {
h := &handlers{
h := newTestHandlers(lookup, chatdURL, nil)
r := chi.NewRouter()
h.register(r)
return r
}
// newTestHandlers exposes the underlying handlers struct so tests can
// inspect post-call state (e.g. session log contents). sessionLog is
// optional — pass nil to disable the session log.
func newTestHandlers(lookup validator.WorkerLookup, chatdURL string, sessionLog *validator.SessionLogger) *handlers {
return &handlers{
lookup: lookup,
chatdURL: chatdURL,
chatClient: &http.Client{Timeout: 5 * time.Second},
@@ -25,10 +64,8 @@ func newTestRouter(lookup validator.WorkerLookup, chatdURL string) http.Handler
DefaultMaxIterations: 3,
DefaultMaxTokens: 4096,
},
sessionLog: sessionLog,
}
r := chi.NewRouter()
h.register(r)
return r
}
// ─── /validate ─────────────────────────────────────────────────
@@ -244,6 +281,135 @@ func TestIterate_MaxIterReturns422WithHistory(t *testing.T) {
}
}
// TestIterate_WritesSessionRowOnSuccess locks the offline-analysis
// contract: every successful /iterate call appends one SessionRecord
// to the configured JSONL with grounded_in_roster=true (because the
// model emitted real candidate IDs that exist in the roster).
func TestIterate_WritesSessionRowOnSuccess(t *testing.T) {
city, role := "Toledo", "Welder"
lookup := validator.NewInMemoryWorkerLookup([]validator.WorkerRecord{
{CandidateID: "W-1", Name: "Ada", Status: "active", City: &city, Role: &role},
})
server := fakeChatd(t, `{"fills":[{"candidate_id":"W-1","name":"Ada"}]}`)
defer server.Close()
dir := t.TempDir()
logPath := filepath.Join(dir, "sessions.jsonl")
h := newTestHandlers(lookup, server.URL, validator.NewSessionLogger(logPath))
r := chi.NewRouter()
h.register(r)
body, _ := json.Marshal(map[string]any{
"kind": "fill",
"prompt": "produce fill",
"provider": "ollama",
"model": "qwen3.5:latest",
"context": map[string]any{"target_count": 1, "city": "Toledo", "role": "Welder", "client_id": "C-1"},
})
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d (body=%s)", w.Code, w.Body.String())
}
rows := readSessionLog(t, logPath)
if len(rows) != 1 {
t.Fatalf("expected 1 session row, got %d", len(rows))
}
row := rows[0]
if row["schema"] != validator.SessionRecordSchema {
t.Errorf("schema: %v", row["schema"])
}
if row["final_verdict"] != "accepted" {
t.Errorf("final_verdict: %v", row["final_verdict"])
}
if row["grounded_in_roster"] != true {
t.Errorf("grounded_in_roster: expected true (W-1 is in roster), got %v", row["grounded_in_roster"])
}
if row["kind"] != "fill" {
t.Errorf("kind: %v", row["kind"])
}
}
// TestIterate_GroundedFalseWhenArtifactReferencesPhantom is the
// load-bearing forensic property J flagged 2026-05-02: phantom
// worker IDs in the final accepted artifact must surface as
// grounded_in_roster=false in the session log. This shouldn't happen
// in practice (FillValidator catches phantoms before they reach this
// row), but the explicit check guards against a future bug where the
// validator weakens or a different validator path skips the roster
// check. Defense-in-depth at the observability layer.
func TestIterate_GroundedFalseWhenArtifactReferencesPhantom(t *testing.T) {
// Empty roster — every candidate ID is "phantom". Plus we use
// the playbook kind so the validator doesn't reject the artifact
// upfront (PlaybookValidator doesn't consult the roster), letting
// the artifact reach the session log. We then build a custom
// rosterCheckFor "fill" closure and exercise it directly.
lookup := validator.NewInMemoryWorkerLookup(nil)
h := newTestHandlers(lookup, "", nil)
check := h.rosterCheckFor("fill")
if check == nil {
t.Fatal("expected non-nil checker for fill kind")
}
got := check(map[string]any{
"fills": []any{
map[string]any{"candidate_id": "W-PHANTOM-NEVER-EXISTS"},
},
})
if got == nil || *got {
t.Errorf("expected grounded=false for phantom candidate, got %v", got)
}
// Other kinds: no roster concept → checker returns nil.
if h.rosterCheckFor("email") != nil {
t.Error("email kind should not have a roster checker")
}
if h.rosterCheckFor("playbook") != nil {
t.Error("playbook kind should not have a roster checker")
}
}
// TestIterate_WritesSessionRowOnFailure: max-iter exhaustion still
// produces a row (omits artifact, captures the retry chain).
func TestIterate_WritesSessionRowOnFailure(t *testing.T) {
server := fakeChatd(t, "no json here, just prose")
defer server.Close()
dir := t.TempDir()
logPath := filepath.Join(dir, "sessions.jsonl")
h := newTestHandlers(validator.NewInMemoryWorkerLookup(nil), server.URL,
validator.NewSessionLogger(logPath))
r := chi.NewRouter()
h.register(r)
body, _ := json.Marshal(map[string]any{
"kind": "playbook",
"prompt": "produce X",
"provider": "ollama",
"model": "x",
"max_iterations": 2,
})
req := httptest.NewRequest("POST", "/iterate", bytes.NewReader(body))
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", w.Code)
}
rows := readSessionLog(t, logPath)
if len(rows) != 1 {
t.Fatalf("expected 1 session row, got %d", len(rows))
}
if rows[0]["final_verdict"] != "max_iter_exhausted" {
t.Errorf("final_verdict: %v", rows[0]["final_verdict"])
}
if _, hasArtifact := rows[0]["artifact"]; hasArtifact {
t.Errorf("artifact should be omitted on failure: %v", rows[0]["artifact"])
}
}
func TestIterate_ChatdDownReturns502(t *testing.T) {
r := newTestRouter(validator.NewInMemoryWorkerLookup(nil), "http://127.0.0.1:1") // unroutable
body, _ := json.Marshal(map[string]any{
+26 -12
View File
@@ -4,7 +4,7 @@
> comparison.
> **Owner**: J. Update this when either side ships a fix that changes
> the table values, or when a new architectural axis surfaces.
> **Last meaningful refresh**: 2026-05-01 (post-Rust-cache + Go-validator-port)
> **Last meaningful refresh**: 2026-05-02 (post-Lance-gauntlet + observability parity)
This document compares the two parallel implementations of the lakehouse
substrate — Rust at `/home/profit/lakehouse/` (production today), Go at
@@ -48,13 +48,19 @@ Don't:
| 2026-05-01 | Multi-tier load test against 100k corpus | 335k scenarios in 5min, 4/6 at 0% fail. Surfaced coder/hnsw v0.6.1 bug. Recover guard added. **DONE** (multitier_100k.md). |
| 2026-05-01 | **coder/hnsw v0.6.1 panic — REAL FIX landed** | Lifted source-of-truth out of coder/hnsw via `i.vectors map[string][]float32` side store + `safeGraphAdd`/`safeGraphDelete` recover wrappers + warm-path rebuild fallback. Re-run: 0 failures across 19,622 scenarios (was 96-98% on 2/6). **DONE.** Architecture invariant in STATE_OF_PLAY "DO NOT RELITIGATE". |
| 2026-05-02 | **Substrate fix verified at original failure scale** | Re-ran multitier 5min @ conc=50 (the footprint that originally surfaced the bug at 96-98% fail). Result: 132,211 scenarios at 438/sec, **6/6 classes at 0% failure**. Throughput dropped 1,115/sec → 438/sec because broken scenarios now do real HNSW Add work. Tails healthy: surge_fill_validate p99=1.53s, playbook_record_replay p99=2.32s. **Fix scales — closing the open thread.** |
| _open_ | Drop Python sidecar from Rust aibridge | Universal-win architectural cleanup. ~200 LOC, removes 1 runtime + 1 process. |
| 2026-05-02 | **Drop Python sidecar from Rust aibridge — DONE** | `crates/aibridge/src/client.rs` rewrite (commit `ba928b1` in lakehouse). AiClient now talks Ollama directly: per-text `/api/embed` loop, `/api/generate` for chat + rerank-loop + admin (unload/preload), `/api/ps` + `std::process::Command nvidia-smi` for vram_snapshot. Public API unchanged — 0 callers updated. Verification: cargo test -p aibridge 32/32 PASS, live smoke `/ai/embed` returns 768-dim vector + `/v1/chat` returns "OK". Sidecar's `lab_ui.py` + `pipeline_lab.py` (~888 LOC dev-only UIs) keep running; only the hot-path embed/generate/rerank/admin routes are retired. Process count drops from "mega-binary + sidecar" to "mega-binary alone". |
| 2026-05-02 | **Rust observability parity (trace-id + session JSONL) — DONE** | Mirrors the Go-side wave (commits `d6d2fdf` + `1a3a82a` in golangLAKEHOUSE). `crates/gateway/src/v1/iterate.rs` now reads `X-Lakehouse-Trace-Id` header, forwards to `/v1/chat` + `/v1/validate` hops, emits per-attempt Langfuse spans (`iterate.attempt[N]`), and writes one `SessionRecord` JSONL row per session via the new `crates/gateway/src/v1/session_log.rs` writer. New `crates/gateway/src/bin/parity_session_log` helper enables the cross-runtime parity probe at `scripts/cutover/parity/session_log_parity.sh` — **4/4 fixtures byte-equal** (including unicode prompts) after normalizing the 2 fields that legitimately differ (timestamp, daemon name). Both runtimes can write to the same path and DuckDB queries see them as one stream. 90/90 Rust unit tests PASS, 33 Go packages PASS. |
| 2026-05-02 | **Cross-runtime parity gap closure — DONE** | Two follow-ups from the deploy + reality wave: (1) Go validatord didn't honor `X-Lakehouse-Trace-Id` when Langfuse middleware was a passthrough (no env config) — fixed by reading the header DIRECTLY in the iterate handler before the ctx fallback (Go commit `6847bbc`). (2) Rust `IterateResponse`/`IterateFailure` didn't echo `trace_id` back to callers — added the field with skip-serializing-if-none for backward compat (Rust commit `98b6647`). Plus enabled `[gateway].session_log_path` on live Rust pointing at the SAME path Go writes to, so DuckDB queries see one unified longitudinal log. **Verified end-to-end**: identical /v1/iterate to both runtimes lands in one sessions.jsonl tagged `daemon=gateway` (Rust) vs `daemon=validatord` (Go); response bodies both echo the forwarded trace_id. 24/24 parity assertions (validator+extract_json+session_log+materializer) hold post-restart. |
| 2026-05-02 | **Port Rust materializer to Go (transforms.ts) — DONE** | `internal/materializer` + `cmd/materializer` + `materializer_smoke.sh`. Ports `transforms.ts` (12 transforms) + `build_evidence_index.ts`. Idempotency, day-partition, receipt. 14 tests green; on-wire JSON matches TS so both runtimes interoperate. |
| 2026-05-02 | **Port Rust replay tool to Go — DONE** | `internal/replay` + `cmd/replay` + `replay_smoke.sh`. Ports `replay.ts` retrieve → bundle → /v1/chat → validate → log. Closes audit-FULL phase 7 live invocation on Go side. 14 tests green; same `data/_kb/replay_runs.jsonl` shape (schema=replay_run.v1) as TS. |
| 2026-05-02 | **`/v1/validate` + `/v1/iterate` HTTP surface — DONE** | `cmd/validatord` (port 3221) hosts both endpoints. `internal/validator` gains `PlaybookValidator` (3rd kind), JSONL roster loader, and the `Iterate` orchestrator + `ExtractJSON` helper. Gateway proxies `/v1/validate` + `/v1/iterate` to validatord. Closes the last "Go-primary" backlog item (architecture_comparison.md item #7). 30+ tests + `validatord_smoke.sh` 5/5 PASS. |
| 2026-05-02 | **Cross-runtime validator parity probe — surfaced wire-format gap** | New `scripts/cutover/parity/validator_parity.sh` runs 6 identical /v1/validate cases against Rust :3100 AND Go :4110, compares status + body. Result: **6/6 status codes match (logic-level equivalence holds), 5/6 body shapes diverge.** Rust returns serde-tagged enum `{"Schema":{"field":"x","reason":"y"}}`; Go returns flat struct `{"Kind":"schema","Field":"x","Reason":"y"}`. Any caller parsing the error envelope would break in cutover. **Open**: pick a target shape (Go matching Rust is the cutover-friendly direction) and align via custom `MarshalJSON` on `ValidationError`. |
| _open_ | **Validator wire-format alignment** | Surfaced by 2026-05-02 parity probe. Choose canonical error JSON shape, align both runtimes. ~50 LOC custom `MarshalJSON` either side. |
| _open_ | Decide on Lance vector backend | Defer until corpus exceeds ~5M rows. |
| 2026-05-02 | **Materializer parity probe — caught + fixed real bug** | New `scripts/cutover/parity/materializer_parity.sh` runs Bun + Go materializer on identical synthetic root, diffs output JSONL. Result on first run: **0/2 match** — Go's `Provenance.LineOffset` had `json:",omitempty"` and stripped the field on first-row records (line_offset=0 is semantically meaningful, not absent). 1-line fix (drop `omitempty` + comment explaining why). Re-run: **2/2 match**. Real cross-runtime gap surfaced + closed in same wave. |
| 2026-05-02 | **extract_json parity probe — 12/12 match across edge cases** | New `scripts/cutover/parity/extract_json_parity.sh` runs identical model-output strings through Rust `gateway::v1::iterate::extract_json` AND Go `validator.ExtractJSON`. 12 fixtures: fenced/unfenced blocks, nested objects, unicode, escaped quotes, top-level array, malformed JSON. Substrate gate: `cargo test -p gateway extract_json` PASS before probe. Result: **12/12 match.** Algorithms genuinely equivalent. Rust side gained `pub` on `extract_json` + new `bin/parity_extract_json` (~30 LOC). |
| 2026-05-02 | **Validator wire-format alignment — DONE** | Custom `MarshalJSON`/`UnmarshalJSON` on Go's `validator.ValidationError` emits the Rust serde-tagged-enum shape `{"Schema":{"field":"x","reason":"y"}}`. UnmarshalJSON also accepts the legacy flat shape (migration safety) and rejects unknown variants (drift guard for future Rust enum additions). 4 new pinning tests in `types_test.go`. Re-run validator parity probe: **6/6 match** (was 1/6). |
| 2026-05-02 | **Lance backend gauntlet (4-pack + root-cause fix) — DONE** | Lance crate had zero tests + no smoke when audited this morning. Shipped: (a) `sanitize_lance_err` over all 5 routes (search/doc/index/append/migrate) — missing-index now 404 not 500, no `/home/` or `/root/.cargo/` paths leaked; (b) 7 unit tests in `crates/vectord-lance` with synth Parquet helper; (c) 9-probe `scripts/lance_smoke.sh` against live `:3100`; (d) 10M re-bench (`reports/lance_10m_rebench_2026-05-02.md`) — search warm ~20ms, search cold ~46ms median. Bench surfaced doc-fetch p50 ~100ms (300x slower than ADR-019 100K projection); root-caused to lance-bench bypassing IndexMeta → warming auto-build never fired → no `doc_id` btree. **Fix shipped (commit `5d30b3d`)**: `lance_migrate` HTTP handler now auto-builds the btree inline (1.2s on 10M, +269MB), drops doc-fetch to ~5ms (20x). Live verified 9/9 smoke + post-restart doc-fetch 4-15ms. |
| 2026-05-03 | **Subject manifests + per-subject HMAC audit log — DONE on Rust + Go** | Local-first compliance substrate per `lakehouse/docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md` Steps 1-8. Rust shipped: `SubjectManifest` type + Registry CRUD (`crates/catalogd/src/registry.rs`), `SubjectAuditWriter` with HMAC-SHA256 chain + per-subject Mutex serialization + canonical-JSON via BTreeMap (`subject_audit.rs`), backfill ETL (`bin/backfill_subjects`), gateway tool dispatch + validator decorator wiring, legal-tier `/audit/subject/{id}` endpoint with constant-time-eq token + tampering detection, daily `bin/retention_sweep` (BIPA-aware, idempotent, no auto-mutation). Go shipped: identical `internal/catalogd/subject.go` reader + `VerifyChain` over RAW LINE BYTES (avoids time-precision drift), 11 unit tests. **6th cross-runtime parity probe**: `scripts/cutover/parity/subject_audit_parity.sh` — 6/6 byte-identical assertions across known-answer fixture + 5 real production audit logs. Surfaced + closed three drift classes in authoring loop: (1) Go `omitempty` stripping `trace_id:""`; (2) `time.RFC3339Nano` truncating trailing-zero nanoseconds where chrono AutoSi keeps 9 digits; (3) Go `json.Marshal` HTML-escaping `<>&` where serde keeps literal — fixed via `marshalNoEscapeHTML` + raw-bytes canonicalization. Two cross-lineage scrums caught real bugs each round (chain corruption race, schema-evolution HMAC drift, hardcoded "success" classifier, token min length, chain_root from windowed slice, tampering detection, HTML escape divergence). |
| _open_ | Decide Lance vs Parquet+HNSW for primary | Lance verified production-ready at 10M (this morning's gauntlet). HNSW at 10M doesn't fit RAM (~60GB for vectors+graph), so the comparison is between Lance and Parquet+HNSW-with-spilling. Decide once we have a 10M ingest scenario where the Parquet path is bottlenecked. |
| _open_ | Pick Go primary vs Rust primary | Both viable. Go has perf edge after today; Rust has production deploy + producer-side completeness. |
---
@@ -95,17 +101,24 @@ up). Go also lets you tune per-daemon resource limits via systemd.
## Python dependency (the load-bearing axis)
This is the architectural difference that drove the original perf gap.
Both call Ollama at `:11434`, but the path differs:
This was the architectural difference that drove the original perf gap.
```
Rust embed: gateway → HTTP → Python sidecar :3200 → HTTP → Ollama :11434
Go embed: gateway → HTTP → Go embedd :4216 → HTTP → Ollama :11434
Pre-2026-05-02:
Rust embed: gateway → HTTP → Python sidecar :3200 → HTTP → Ollama :11434
Go embed: gateway → HTTP → Go embedd :4216 → HTTP → Ollama :11434
Post-2026-05-02 (commit ba928b1):
Rust embed: gateway → HTTP → Ollama :11434 (sidecar dropped)
Go embed: gateway → HTTP → Go embedd :4216 → HTTP → Ollama :11434
```
The Python sidecar (`sidecar/sidecar/main.py`, 1,237 lines) is a
FastAPI wrapper around Ollama. It does pydantic validation + request
shaping; **no fundamental compute** that Ollama can't do directly.
The hot-path Python sidecar (~120 LOC across embed.py / generate.py
/ rerank.py / admin.py) was pure pass-through to Ollama and added no
logic — just translation. AiClient was rewritten 2026-05-02 to call
Ollama directly. `lab_ui.py` + `pipeline_lab.py` (~888 LOC of
Streamlit-shape dev UIs) remain as ad-hoc tooling, not on the
runtime hot path.
### Performance impact (load-tested 2026-05-01, 6 rotating bodies, 10 concurrency, 30s)
@@ -268,7 +281,7 @@ The list below is a working backlog. Move items to "Decisions tracker"
1. ✅ **Embed cache in Rust aibridge** — DONE 2026-05-01 (`150cc3b`).
2. ✅ **FillValidator + EmailValidator in Go** — DONE 2026-05-01 (`b03521a`).
3. **Drop Python sidecar from Rust** — Rewrite aibridge to call Ollama at `:11434/api/embed` and `/api/generate` directly. Removes 1 runtime + 1 process from ops. ~200 LOC.
3. ✅ **Drop Python sidecar from Rust** — DONE 2026-05-02 (`ba928b1`).
4. **Cross-runtime contract tests** — Pin shared JSONL schemas (audit_baselines, scored_run, sft_sample) as canonical specs in `auditor/schemas/` with Go-side validators consuming the same definitions.
### If keeping Go primary
@@ -318,6 +331,7 @@ Append entries here when this doc gets updated. One-line entries; link to commit
- 2026-05-02 — Substrate fix verified at original failure-surfacing scale. Multitier 5min @ conc=50: 132,211 scenarios at 438/sec, 6/6 classes at 0% failure (was 4/6 pre-fix). Throughput drop (1,115 → 438/sec) is the honest cost of the formerly-broken scenarios doing real HNSW Add work. STATE_OF_PLAY refreshed to 2026-05-02.
- 2026-05-02 — Materializer + replay tool ported from Rust legacy to Go (`internal/materializer` + `internal/replay`, both with CLI + smoke + tests). Both runtimes now produce the same `data/evidence/YYYY/MM/DD/*.jsonl` and `data/_kb/replay_runs.jsonl` shapes; Go side no longer needs Bun for these phases.
- 2026-05-02 — `/v1/validate` + `/v1/iterate` HTTP surface ported as `cmd/validatord` on `:3221`. Closes the last "If keeping Go primary" backlog item — Go now owns the entire validator path end-to-end (no Rust dep for staffing safety net). 5/5 smoke probes via gateway :3110.
- 2026-05-02 — Python sidecar dropped from Rust hot path (`ba928b1`). AiClient now talks Ollama directly. Process count: mega-binary + sidecar → mega-binary alone. Public API unchanged, 0 callers updated. Cargo tests 32/32 PASS, live `/ai/embed` + `/v1/chat` smokes green on test gateway. Lab UI Python remains as ad-hoc dev tooling.
---
+158
View File
@@ -0,0 +1,158 @@
# Coordinator session log — `coordinator_sessions.jsonl`
**Last updated:** 2026-05-02 · **Schema:** `session.iterate.v1` · **Writer:** `internal/validator.SessionLogger` · **Producer:** validatord `/v1/iterate`
## Why
The Langfuse trace tree is the live view: per-session, you can scroll
the retry chain and inspect every sub-call. But for **longitudinal
forensics** ("show me every session in the last week where the model
guessed a real worker without a retry," or "find sessions where
validation rejected three times in a row"), Langfuse's UI doesn't
scale — you need a queryable data plane.
This JSONL is that plane. One row per `/v1/iterate` session, append-
only, DuckDB-friendly.
## Where
Configurable via `[validatord].session_log_path` in `lakehouse.toml`.
Empty = disabled (best-effort posture; a missing log never blocks an
iterate request). Production:
```toml
[validatord]
session_log_path = "/var/lib/lakehouse/validator/sessions.jsonl"
```
## Schema (v1)
```jsonc
{
"schema": "session.iterate.v1",
"session_id": "<Langfuse trace_id>", // join key to Langfuse
"timestamp": "2026-05-02T07:30:00.123456Z",
"daemon": "validatord",
"kind": "fill | email | playbook",
"model": "qwen3.5:latest",
"provider": "ollama",
"prompt": "produce a fill artifact ...", // truncated to 4000 chars
"iterations": 3, // attempts spent
"max_iterations": 3, // cap per request
"final_verdict": "accepted | max_iter_exhausted | infra_error",
"attempts": [
{ "iteration": 0, "verdict_kind": "validation_failed",
"error": "consistency: candidate_id W-X not in roster",
"span_id": "abc..." },
{ "iteration": 1, "verdict_kind": "validation_failed",
"error": "consistency: city mismatch", "span_id": "def..." },
{ "iteration": 2, "verdict_kind": "accepted", "span_id": "ghi..." }
],
"artifact": { /* final accepted artifact, omitted on failure */ },
"grounded_in_roster": true, // null when N/A (email/playbook)
"duration_ms": 2840
}
```
### Field semantics
| Field | When set | What it means |
|---|---|---|
| `session_id` | always | Langfuse trace id. Pivot to live trace tree by URL: `${LANGFUSE_URL}/trace/<session_id>`. |
| `final_verdict=accepted` | success | Loop converged within `max_iterations`. `artifact` is non-null. |
| `final_verdict=max_iter_exhausted` | failure | Loop hit the cap without passing validation. `artifact` is omitted. |
| `final_verdict=infra_error` | failure | Chat hop or other infra crashed. Single attempt with `verdict_kind=infra_error`. |
| `grounded_in_roster=true` | fill kind, success | Every `candidate_id` in the artifact exists in `WorkerLookup`. |
| `grounded_in_roster=false` | fill kind, anomaly | Phantom or otherwise-invalid candidate IDs (shouldn't happen — FillValidator catches these — but the explicit check defends against future validator weakening). |
| `grounded_in_roster=null` (omitted) | non-fill kinds, or failure | The roster check doesn't apply or wasn't run. |
## DuckDB queries
```sql
-- Read the log directly via DuckDB's read_json_auto.
ATTACH ':memory:' AS sessions;
SELECT * FROM read_json_auto(
'/var/lib/lakehouse/validator/sessions.jsonl', format='newline_delimited'
) LIMIT 10;
```
### "Did the validator catch every phantom worker?"
```sql
-- Sessions where iteration 0's verdict was validation_failed AND
-- the error mentions 'phantom' or 'consistency'. If grounded=true on
-- the same session's final state, the model recovered.
SELECT session_id, model, iterations, grounded_in_roster, final_verdict
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
WHERE final_verdict = 'accepted'
AND iterations > 1
AND list_contains(
list_transform(attempts,
x -> x.error LIKE '%consistency%' OR x.error LIKE '%phantom%'),
true
);
```
### "First-shot success rate per model" (the "did the corpus give it enough" gate)
```sql
SELECT model,
COUNT(*) AS sessions,
SUM(CASE WHEN iterations = 1 AND final_verdict = 'accepted' THEN 1 ELSE 0 END) AS first_shot,
ROUND(100.0 * SUM(CASE WHEN iterations = 1 AND final_verdict = 'accepted' THEN 1 ELSE 0 END) / COUNT(*), 1) AS pct
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
WHERE kind = 'fill'
GROUP BY model
ORDER BY pct DESC;
```
### "Sessions that were never grounded" (the alarm query)
```sql
-- Should always be empty. If it isn't, FillValidator has a hole or
-- a different code path is bypassing the roster check.
SELECT session_id, model, iterations, attempts
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
WHERE kind = 'fill'
AND final_verdict = 'accepted'
AND grounded_in_roster = false;
```
### "Average retry depth per model"
```sql
SELECT model, AVG(iterations) AS avg_iter, COUNT(*) AS n
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
WHERE kind = 'fill' AND final_verdict = 'accepted'
GROUP BY model
ORDER BY avg_iter ASC;
```
### "What did validation reject?" (failure mode breakdown)
```sql
-- Pull each rejected attempt's error string, classify by prefix.
WITH errors AS (
SELECT session_id,
model,
unnest(attempts) AS att
FROM read_json_auto('sessions.jsonl', format='newline_delimited')
)
SELECT model,
split_part(att.error, ':', 1) AS kind,
COUNT(*) AS n
FROM errors
WHERE att.verdict_kind = 'validation_failed'
GROUP BY model, kind
ORDER BY n DESC;
```
## Operational notes
- **Append-only.** No row is ever updated; storage grows linearly with iterate calls. Operators rotate via cron when the file gets unwieldy (logrotate-style).
- **Best-effort posture.** Every write goes through `slog.Warn` on failure but never blocks the iterate handler. A full disk silently drops session rows; the iterate response still ships.
- **Schema versioning.** `schema=session.iterate.v1` is the contract. Future incompatible changes bump the version; consumers should branch on the field.
- **PII consideration.** `prompt` is captured truncated to 4000 chars and the final `artifact` (when present) is captured verbatim. Operators handling PII-bearing prompts should set the path under a restricted-access volume or filter before retention.
- **Cross-runtime parity.** The Rust gateway's `/v1/iterate` does NOT yet write this file. If you want a unified longitudinal log across runtimes, port the writer to Rust (`crates/gateway/src/v1/iterate.rs`) and target the same JSONL path. ~50 LOC.
## See also
- `internal/validator/session_log.go` — writer + record types
- `internal/validator/iterate.go` — `Tracer` callback + Langfuse span emission
- `internal/shared/langfuse_middleware.go` — `X-Lakehouse-Trace-Id` header propagation (the `session_id` join key)
- `data/_kb/replay_runs.jsonl` — the *replay* tool's own JSONL (different shape, different producer); these two streams are siblings, not duplicates
+371
View File
@@ -0,0 +1,371 @@
// Subject manifests + per-subject audit-log chain verification.
//
// Specification: /home/profit/lakehouse/docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md.
//
// This file is the Go side of Step 8 (cross-runtime parity). The Rust
// side is the writer (crates/catalogd/src/subject_audit.rs); Go is a
// READER + VERIFIER only — both sides serialize / hash with identical
// algorithms so a chain written by Rust verifies under Go.
//
// Algorithm (must match Rust crates/catalogd/src/subject_audit.rs exactly):
// row_hmac = HMAC-SHA256(key, prev_chain_hash_bytes || canonical_row_bytes)
//
// where:
// - prev_chain_hash_bytes is the ASCII bytes of the previous row's
// row_hmac field, OR the literal ASCII string "GENESIS" for the
// first row (no hex decode!)
// - canonical_row_bytes is the row JSON with the row_hmac field
// dropped, with all object keys sorted alphabetically at every
// nesting depth, with no insignificant whitespace
// - the final hash is rendered as 64 lowercase hex characters
package catalogd
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"time"
)
const GenesisHash = "GENESIS"
// SubjectManifest mirrors crates/shared/src/types.rs::SubjectManifest.
// Keep field tags byte-identical so manifest JSON written by Rust round-trips.
//
// Vertical: omitempty matches Rust's serde-default deserializer accepting
// missing field (default Unknown). For the OTHER fields we deliberately
// avoid omitempty since Rust always emits them.
type SubjectManifest struct {
Schema string `json:"schema"`
CandidateID string `json:"candidate_id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Status string `json:"status"`
Vertical string `json:"vertical,omitempty"`
Consent SubjectConsent `json:"consent"`
Retention SubjectRetention `json:"retention"`
Datasets []SubjectDatasetRef `json:"datasets"`
SafeViews []string `json:"safe_views"`
AuditLogPath string `json:"audit_log_path"`
AuditLogChainRoot string `json:"audit_log_chain_root"`
BiometricCollection *BiometricCollection `json:"biometric_collection,omitempty"`
}
// BiometricCollection mirrors crates/shared/src/types.rs::BiometricCollection.
// Written by the Phase 1.6 Gate 3 photo-upload endpoint. None until the
// first biometric upload; cleared on BIPA erasure.
type BiometricCollection struct {
DataPath string `json:"data_path"`
TemplateHash string `json:"template_hash"`
CollectedAt time.Time `json:"collected_at"`
ConsentVersionHash string `json:"consent_version_hash,omitempty"`
Classifications json.RawMessage `json:"classifications,omitempty"`
}
type SubjectConsent struct {
GeneralPii GeneralPiiConsent `json:"general_pii"`
Biometric BiometricConsent `json:"biometric"`
}
type GeneralPiiConsent struct {
Status string `json:"status"`
Version string `json:"version,omitempty"`
GivenAt *time.Time `json:"given_at,omitempty"`
WithdrawnAt *time.Time `json:"withdrawn_at,omitempty"`
}
type BiometricConsent struct {
Status string `json:"status"`
RetentionUntil *time.Time `json:"retention_until,omitempty"`
}
type SubjectRetention struct {
GeneralPiiUntil time.Time `json:"general_pii_until"`
Policy string `json:"policy,omitempty"`
}
type SubjectDatasetRef struct {
Name string `json:"name"`
KeyColumn string `json:"key_column"`
KeyValue string `json:"key_value"`
}
// SubjectAuditRow mirrors crates/shared/src/types.rs::SubjectAuditRow.
// JSON field order does NOT matter (canonicalizer sorts at hash time)
// but field names + types MUST match for round-trip.
type SubjectAuditRow struct {
Schema string `json:"schema"`
Ts time.Time `json:"ts"`
CandidateID string `json:"candidate_id"`
Accessor AuditAccessor `json:"accessor"`
FieldsAccessed []string `json:"fields_accessed"`
Result string `json:"result"`
PrevChainHash string `json:"prev_chain_hash"`
RowHmac string `json:"row_hmac"`
}
// AuditAccessor mirrors crates/shared/src/types.rs::AuditAccessor.
//
// IMPORTANT: trace_id has NO omitempty — Rust's serde always writes
// the field (even empty) because `#[serde(default)]` only affects
// READS. Stripping it here produces different canonical bytes than
// the writer used → HMAC mismatch on real production rows. (Caught
// 2026-05-03 by subject_audit_parity.sh on a live WORKER-1 row with
// trace_id="".)
type AuditAccessor struct {
Kind string `json:"kind"`
Daemon string `json:"daemon"`
Purpose string `json:"purpose"`
TraceID string `json:"trace_id"`
}
// LoadSubjectManifest reads + parses a subject manifest JSON file.
func LoadSubjectManifest(path string) (*SubjectManifest, error) {
bytes, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read manifest %s: %w", path, err)
}
var m SubjectManifest
if err := json.Unmarshal(bytes, &m); err != nil {
return nil, fmt.Errorf("parse manifest %s: %w", path, err)
}
return &m, nil
}
// AuditLogEntry is one parsed audit row PLUS the raw line bytes from
// the file. The raw bytes are load-bearing: we MUST canonicalize from
// them (not from the re-marshaled struct) to avoid time-precision drift.
//
// Why: Rust's chrono RFC3339-AutoSi serializes nanoseconds with 9 digits
// (e.g. "461439210"). Go's time.RFC3339Nano strips trailing zeros (e.g.
// "46143921"). Round-tripping through time.Time changes the byte sequence
// and breaks the HMAC. Read once, hash from the original bytes.
// (Caught 2026-05-03 by subject_audit_parity.sh on a real WORKER-5 row
// whose nanoseconds happened to end in 0.)
type AuditLogEntry struct {
Row SubjectAuditRow
Raw []byte
}
// ReadAuditLog parses an audit JSONL file. Returns one AuditLogEntry
// per non-empty line. Defensive: blank lines are skipped, unparseable
// lines are skipped (matches Rust read_rows_in_range).
func ReadAuditLog(path string) ([]AuditLogEntry, error) {
bytes, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read audit log %s: %w", path, err)
}
var entries []AuditLogEntry
for _, line := range strings.Split(string(bytes), "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
var row SubjectAuditRow
if err := json.Unmarshal([]byte(trimmed), &row); err != nil {
continue
}
entries = append(entries, AuditLogEntry{Row: row, Raw: []byte(trimmed)})
}
return entries, nil
}
// canonicalRowBytesFromRaw is the production canonicalizer used by
// VerifyChain. It accepts the raw line bytes from the audit JSONL,
// drops the row_hmac key, sorts all keys alphabetically at every
// nesting depth, and re-emits compact JSON. Numbers + strings pass
// through verbatim (json.Number preserves number text, strings are
// re-JSON-escaped identically to Rust's serde_json).
//
// CRITICAL: hashing must always go through this function. NEVER hash
// the bytes produced by canonicalRowBytesFromStruct against a real
// production chain — the time-precision drift will give wrong HMACs
// for any row whose nanoseconds end in 0.
func canonicalRowBytesFromRaw(rawLine []byte) ([]byte, error) {
dec := json.NewDecoder(strings.NewReader(string(rawLine)))
dec.UseNumber()
var v any
if err := dec.Decode(&v); err != nil {
return nil, fmt.Errorf("decode raw line: %w", err)
}
if obj, ok := v.(map[string]any); ok {
delete(obj, "row_hmac")
}
var buf strings.Builder
if err := writeCanonical(&buf, v); err != nil {
return nil, err
}
return []byte(buf.String()), nil
}
// canonicalRowBytesFromStruct is for the parity probe's known-answer
// vector + tests where we WANT to canonicalize a Go-built row from
// scratch (no original bytes exist yet). Production verification uses
// canonicalRowBytesFromRaw against the file bytes; this function is
// only safe for synthetic inputs whose ts has no trailing-zero nanos.
func canonicalRowBytesFromStruct(row *SubjectAuditRow) ([]byte, error) {
raw, err := json.Marshal(row)
if err != nil {
return nil, fmt.Errorf("marshal row: %w", err)
}
return canonicalRowBytesFromRaw(raw)
}
// marshalNoEscapeHTML wraps json.Encoder with HTML escaping disabled.
//
// Why: Go's json.Marshal escapes `<`, `>`, `&` to `<`, `>`,
// `&` by default. Rust's serde_json::to_vec keeps them literal.
// Any string field containing one of those characters would produce
// different canonical bytes across runtimes → broken HMAC chain.
// (Caught 2026-05-03 by opus scrum WARN on parity_subject_audit.rs:
// canonical_json — initially undetected because no production audit
// field contained `<>&`, but realistic for purpose strings like
// "error & retry" or trace_id "<HTTP-Request-Id>".)
//
// Also strips the trailing newline json.Encoder appends — that newline
// is meaningful to JSONL consumers but is junk for hash input.
func marshalNoEscapeHTML(v any) ([]byte, error) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
if err := enc.Encode(v); err != nil {
return nil, err
}
out := buf.Bytes()
return bytes.TrimRight(out, "\n"), nil
}
// writeCanonical recursively writes v as canonical JSON: object keys
// sorted alphabetically, no insignificant whitespace. Arrays preserve
// element order (semantically significant per spec §3).
//
// All scalar emission goes through marshalNoEscapeHTML so the byte
// sequence matches Rust's serde_json output character-for-character.
func writeCanonical(buf *strings.Builder, v any) error {
switch t := v.(type) {
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
buf.WriteByte('{')
for i, k := range keys {
if i > 0 {
buf.WriteByte(',')
}
ks, err := marshalNoEscapeHTML(k)
if err != nil {
return fmt.Errorf("marshal key: %w", err)
}
buf.Write(ks)
buf.WriteByte(':')
if err := writeCanonical(buf, t[k]); err != nil {
return err
}
}
buf.WriteByte('}')
case []any:
buf.WriteByte('[')
for i, elem := range t {
if i > 0 {
buf.WriteByte(',')
}
if err := writeCanonical(buf, elem); err != nil {
return err
}
}
buf.WriteByte(']')
default:
bs, err := marshalNoEscapeHTML(v)
if err != nil {
return fmt.Errorf("marshal scalar: %w", err)
}
buf.Write(bs)
}
return nil
}
// CanonicalAndHmac is a helper for the parity probe: canonicalizes the
// row from scratch (struct → JSON → canonical), computes the HMAC
// against the given prev_hash, returns both. Used only for the
// known-answer fixture; production verification uses VerifyChain
// (which canonicalizes from the original file bytes to dodge time
// precision drift).
func CanonicalAndHmac(row *SubjectAuditRow, key []byte, prevHash string) (canonical []byte, hmacHex string, err error) {
canonical, err = canonicalRowBytesFromStruct(row)
if err != nil {
return nil, "", err
}
hmacHex = computeRowHMAC(key, prevHash, canonical)
return canonical, hmacHex, nil
}
// computeRowHMAC = HMAC-SHA256(key, prev_hash_ascii_bytes || canonical_row_bytes)
// rendered as 64-char lowercase hex.
func computeRowHMAC(key []byte, prevHash string, canonical []byte) string {
mac := hmac.New(sha256.New, key)
mac.Write([]byte(prevHash))
mac.Write(canonical)
return hex.EncodeToString(mac.Sum(nil))
}
// VerifyChain replays the chain for a subject's audit log. Returns the
// number of rows verified, the chain tip (last row's row_hmac, or
// GENESIS when log is empty), and an error describing the first chain
// break found.
//
// CRITICAL: canonicalizes from each entry's RAW LINE BYTES, not from
// the parsed struct. Round-tripping through encoding/json + time.Time
// strips trailing zeros from RFC3339 nanos and produces different
// canonical bytes than the Rust writer used (which used chrono's
// AutoSi 9-digit format). Catches: 2026-05-03 WORKER-5 nanos
// 461439210 → Rust emits 9 digits, Go's time.RFC3339Nano emits 8.
//
// Special case: empty/missing log returns (0, GENESIS, nil).
func VerifyChain(entries []AuditLogEntry, key []byte) (count int, chainTip string, err error) {
prev := GenesisHash
for i, entry := range entries {
if entry.Row.PrevChainHash != prev {
return i, prev, fmt.Errorf("chain break at row %d: prev_chain_hash=%q expected=%q",
i+1, entry.Row.PrevChainHash, prev)
}
claimed := entry.Row.RowHmac
canonical, cerr := canonicalRowBytesFromRaw(entry.Raw)
if cerr != nil {
return i, prev, fmt.Errorf("canonicalize row %d: %w", i+1, cerr)
}
recomputed := computeRowHMAC(key, prev, canonical)
if recomputed != claimed {
return i, prev, fmt.Errorf("hmac mismatch at row %d: stored=%s recomputed=%s",
i+1, claimed, recomputed)
}
prev = claimed
count = i + 1
}
chainTip = prev
return count, chainTip, nil
}
// LoadKeyFile reads a signing key file. Refuses keys shorter than 32
// bytes (matches Rust: 2026-05-03 kimi BLOCK fix lifted the minimum
// from 16 to 32 to align with HMAC-SHA256 best practice).
func LoadKeyFile(path string) ([]byte, error) {
key, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read key file %s: %w", path, err)
}
if len(key) < 32 {
return nil, fmt.Errorf("signing key is %d bytes; recommend ≥32 bytes for HMAC-SHA256", len(key))
}
return key, nil
}
+364
View File
@@ -0,0 +1,364 @@
package catalogd
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"strings"
"testing"
"time"
)
// deterministicKey is the same fixture key the Rust tests use:
// (0u8..32).collect() — so a Rust-written chain verifies under Go.
func deterministicKey() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i)
}
return k
}
func mkRow(candidateID string, fields []string, prevHash, ts string) SubjectAuditRow {
t, _ := time.Parse(time.RFC3339Nano, ts)
return SubjectAuditRow{
Schema: "subject_audit.v1",
Ts: t,
CandidateID: candidateID,
Accessor: AuditAccessor{
Kind: "gateway_lookup",
Daemon: "gateway",
Purpose: "fill_validation",
TraceID: "",
},
FieldsAccessed: fields,
Result: "success",
PrevChainHash: prevHash,
RowHmac: "", // computed below
}
}
// TestCanonicalJSON_KeysSortedAlphabetically asserts the same property
// the Rust unit test asserts (subject_audit::tests::canonical_json_sorts_keys_alphabetically).
func TestCanonicalJSON_KeysSortedAlphabetically(t *testing.T) {
v := map[string]any{
"z": 1,
"a": 2,
"m": map[string]any{"y": 1, "b": 2},
}
var buf strings.Builder
if err := writeCanonical(&buf, v); err != nil {
t.Fatalf("canonical: %v", err)
}
s := buf.String()
a, m, z := strings.Index(s, "\"a\""), strings.Index(s, "\"m\""), strings.Index(s, "\"z\"")
if !(a < m && m < z) {
t.Fatalf("top-level keys out of order: %s", s)
}
b, y := strings.Index(s, "\"b\""), strings.Index(s, "\"y\"")
if !(b < y) {
t.Fatalf("nested keys out of order: %s", s)
}
}
// TestCanonicalJSON_ArraysPreserveOrder asserts arrays are NOT sorted —
// matches Rust subject_audit::tests::canonical_json_arrays_preserve_order.
func TestCanonicalJSON_ArraysPreserveOrder(t *testing.T) {
v := map[string]any{"k": []any{"c", "a", "b"}}
var buf strings.Builder
if err := writeCanonical(&buf, v); err != nil {
t.Fatalf("canonical: %v", err)
}
if !strings.Contains(buf.String(), "\"c\",\"a\",\"b\"") {
t.Fatalf("array order altered: %s", buf.String())
}
}
// buildEntry produces an AuditLogEntry by computing the HMAC against
// the row's struct-derived canonical bytes, then storing the resulting
// row JSON as the raw bytes. Test-only — production reads raw bytes
// straight from disk so the time-precision drift doesn't apply.
func buildEntry(row SubjectAuditRow, key []byte, prev string) AuditLogEntry {
canon, err := canonicalRowBytesFromStruct(&row)
if err != nil {
panic(err)
}
row.RowHmac = computeRowHMAC(key, prev, canon)
raw, err := json.Marshal(row)
if err != nil {
panic(err)
}
return AuditLogEntry{Row: row, Raw: raw}
}
// TestVerifyChain_ReplaysAndReachesTip writes 3 rows with HMACs computed
// the same way Rust would, then verifies they chain. This is the local
// half of the parity contract — the cross-runtime half (Rust writes,
// Go verifies) is covered by scripts/cutover/parity/subject_audit_parity.sh.
func TestVerifyChain_ReplaysAndReachesTip(t *testing.T) {
key := deterministicKey()
r1 := mkRow("CAND-PARITY", []string{"name"}, GenesisHash, "2026-05-03T12:00:00Z")
e1 := buildEntry(r1, key, GenesisHash)
r2 := mkRow("CAND-PARITY", []string{"phone"}, e1.Row.RowHmac, "2026-05-03T12:00:01Z")
e2 := buildEntry(r2, key, e1.Row.RowHmac)
r3 := mkRow("CAND-PARITY", []string{"email"}, e2.Row.RowHmac, "2026-05-03T12:00:02Z")
e3 := buildEntry(r3, key, e2.Row.RowHmac)
count, tip, err := VerifyChain([]AuditLogEntry{e1, e2, e3}, key)
if err != nil {
t.Fatalf("verify failed: %v", err)
}
if count != 3 {
t.Fatalf("expected 3 rows verified, got %d", count)
}
if tip != e3.Row.RowHmac {
t.Fatalf("chain tip wrong: tip=%s expected=%s", tip, e3.Row.RowHmac)
}
}
// TestVerifyChain_EmptyLogIsTriviallyValid mirrors Rust's empty-log
// special case: 0 rows, GENESIS tip, no error.
func TestVerifyChain_EmptyLogIsTriviallyValid(t *testing.T) {
count, tip, err := VerifyChain(nil, deterministicKey())
if err != nil {
t.Fatalf("empty log returned error: %v", err)
}
if count != 0 {
t.Fatalf("expected 0 rows on empty log, got %d", count)
}
if tip != GenesisHash {
t.Fatalf("expected GENESIS tip on empty log, got %q", tip)
}
}
// TestVerifyChain_TamperDetected: tamper the raw line's `result` field
// (the canonicalizer sees the new bytes; HMAC mismatches the stored hash).
func TestVerifyChain_TamperDetected(t *testing.T) {
key := deterministicKey()
r1 := mkRow("CAND-T", []string{"name"}, GenesisHash, "2026-05-03T12:00:00Z")
e1 := buildEntry(r1, key, GenesisHash)
// Tamper: replace "success" with "denied" in the raw bytes ONLY.
// The struct's row_hmac (used as the "stored" comparator) stays put.
e1.Raw = []byte(strings.Replace(string(e1.Raw), `"success"`, `"denied"`, 1))
_, _, err := VerifyChain([]AuditLogEntry{e1}, key)
if err == nil {
t.Fatal("expected hmac mismatch after tamper, got nil")
}
if !strings.Contains(err.Error(), "hmac mismatch") {
t.Fatalf("expected hmac mismatch, got: %v", err)
}
}
// TestVerifyChain_BadKeyRejectsValidRows: same rows + wrong key = mismatch.
func TestVerifyChain_BadKeyRejectsValidRows(t *testing.T) {
good := deterministicKey()
r1 := mkRow("CAND-BK", []string{"name"}, GenesisHash, "2026-05-03T12:00:00Z")
e1 := buildEntry(r1, good, GenesisHash)
bad := make([]byte, 32)
for i := range bad {
bad[i] = 0xff
}
_, _, err := VerifyChain([]AuditLogEntry{e1}, bad)
if err == nil {
t.Fatal("expected hmac mismatch with wrong key")
}
}
// TestComputeRowHMAC_StableAcrossRuns: same row + same key always = same hash.
func TestComputeRowHMAC_StableAcrossRuns(t *testing.T) {
key := deterministicKey()
r := mkRow("CAND-S", []string{"a", "b"}, GenesisHash, "2026-05-03T12:00:00Z")
c1, _ := canonicalRowBytesFromStruct(&r)
c2, _ := canonicalRowBytesFromStruct(&r)
if string(c1) != string(c2) {
t.Fatalf("canonical bytes unstable across runs:\n c1=%s\n c2=%s", c1, c2)
}
h1 := computeRowHMAC(key, GenesisHash, c1)
h2 := computeRowHMAC(key, GenesisHash, c2)
if h1 != h2 {
t.Fatalf("hmac unstable across runs: %s vs %s", h1, h2)
}
if len(h1) != 64 {
t.Fatalf("hmac wrong length %d", len(h1))
}
// Sanity: hex-decodable.
if _, err := hex.DecodeString(h1); err != nil {
t.Fatalf("hmac not hex: %v", err)
}
}
// TestKnownAnswerVector matches a Go-computed reference. The same
// inputs must produce this exact byte string under Rust as well —
// scripts/cutover/parity/subject_audit_parity.sh runs the Rust helper
// against this exact fixture and asserts byte-identical output.
//
// If you change the fixture, rebuild Rust's parity_subject_audit + Go's
// helper and update both sides together.
func TestKnownAnswerVector(t *testing.T) {
key := deterministicKey()
r := SubjectAuditRow{
Schema: "subject_audit.v1",
Ts: time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC),
CandidateID: "WORKER-FIXED",
Accessor: AuditAccessor{
Kind: "gateway_lookup",
Daemon: "gateway",
Purpose: "parity_test",
TraceID: "trace-fixed",
},
FieldsAccessed: []string{"name"},
Result: "success",
PrevChainHash: GenesisHash,
RowHmac: "",
}
canon, err := canonicalRowBytesFromStruct(&r)
if err != nil {
t.Fatalf("canonical: %v", err)
}
t.Logf("canonical bytes: %s", canon)
hmacHex := computeRowHMAC(key, GenesisHash, canon)
t.Logf("hmac: %s", hmacHex)
// Sanity: round-trip through encoding/json + canonicalization is stable.
again, err := canonicalRowBytesFromStruct(&r)
if err != nil {
t.Fatalf("canonical 2: %v", err)
}
if string(canon) != string(again) {
t.Fatalf("canonical drift: %s vs %s", canon, again)
}
// Sanity: real HMAC against the canonical bytes.
mac := hmac.New(sha256.New, key)
mac.Write([]byte(GenesisHash))
mac.Write(canon)
expected := hex.EncodeToString(mac.Sum(nil))
if hmacHex != expected {
t.Fatalf("computeRowHMAC drift: %s vs %s", hmacHex, expected)
}
}
// TestVerifyChain_HtmlChars_NotEscaped is the regression test for the
// 2026-05-03 opus scrum WARN: Go's json.Marshal escapes `<`, `>`, `&`
// to `<`, `>`, `&` by default; Rust's serde_json keeps
// them literal. Audit rows with these chars in any string field would
// silently break the chain across runtimes. Fix is in writeCanonical's
// marshalNoEscapeHTML helper. This test asserts canonical bytes contain
// the literal `<`, `>`, `&` (proving the fix is in place).
func TestVerifyChain_HtmlChars_NotEscaped(t *testing.T) {
r := mkRow("CAND-HTML", []string{"name"}, GenesisHash, "2026-05-03T12:00:00Z")
r.Accessor.Purpose = "error & retry" // & must NOT be &
r.Accessor.TraceID = "<HTTP-Req-Id>" // < and > must NOT be < / >
canon, err := canonicalRowBytesFromStruct(&r)
if err != nil {
t.Fatalf("canonical: %v", err)
}
s := string(canon)
// FAIL if the bytes contain Go's HTML-safe < / > / &
// escape sequences (six raw chars each: backslash, u, 0, 0, hex, hex).
// Those wouldn't match Rust's literal-char output and would silently
// break the cross-runtime HMAC chain. Note: the strings below are
// raw-string literals — the backslash + u006xx is six literal bytes,
// NOT a Go-source unicode escape.
if strings.Contains(s, "\\u003c") || strings.Contains(s, "\\u003e") || strings.Contains(s, "\\u0026") {
t.Fatalf("canonical bytes contain Go HTML-escape sequences (would diverge from Rust):\n%s", s)
}
// PASS only if the literal chars survived round-trip.
if !strings.Contains(s, "\"<HTTP-Req-Id>\"") || !strings.Contains(s, "\"error & retry\"") {
t.Fatalf("canonical bytes missing literal <>&:\n%s", s)
}
}
// TestVerifyChain_RawBytesPreserveTimePrecision is the regression test
// for the 2026-05-03 WORKER-5 finding: when a row's nanoseconds end in
// 0, time.RFC3339Nano strips the trailing zero on re-marshal, producing
// different canonical bytes than Rust's chrono AutoSi (which always
// emits 9 digits). VerifyChain MUST canonicalize from the raw line
// bytes to avoid this drift. Test feeds a hand-crafted raw line whose
// ts has a trailing-zero nano value and asserts verify succeeds when
// the chain hash was computed against THOSE EXACT bytes.
func TestVerifyChain_RawBytesPreserveTimePrecision(t *testing.T) {
key := deterministicKey()
// Hand-crafted raw line exactly as Rust would write it, with
// nanoseconds=461439210 (trailing zero present).
rawNoHmac := `{"schema":"subject_audit.v1","ts":"2026-05-03T09:12:47.461439210Z","candidate_id":"WORKER-5","accessor":{"kind":"validator_lookup","daemon":"gateway","purpose":"validator_worker_lookup","trace_id":""},"fields_accessed":["exists"],"result":"not_found","prev_chain_hash":"GENESIS"}`
canonical, err := canonicalRowBytesFromRaw([]byte(rawNoHmac))
if err != nil {
t.Fatalf("canonicalize raw: %v", err)
}
hmacHex := computeRowHMAC(key, GenesisHash, canonical)
// Compose the full row by injecting row_hmac at the end (matches
// what the Rust writer produces — declaration order + appended hmac).
rawFull := strings.TrimSuffix(rawNoHmac, "}") + `,"row_hmac":"` + hmacHex + `"}`
var row SubjectAuditRow
if err := json.Unmarshal([]byte(rawFull), &row); err != nil {
t.Fatalf("unmarshal: %v", err)
}
entry := AuditLogEntry{Row: row, Raw: []byte(rawFull)}
count, tip, err := VerifyChain([]AuditLogEntry{entry}, key)
if err != nil {
t.Fatalf("verify failed (regression: time-precision drift): %v", err)
}
if count != 1 {
t.Fatalf("expected 1 row verified, got %d", count)
}
if tip != hmacHex {
t.Fatalf("tip mismatch: %s vs %s", tip, hmacHex)
}
}
// TestSubjectManifest_RoundTripJSON: parse a fixture JSON identical in
// shape to what crates/catalogd/src/registry.rs::put_subject writes to
// data/_catalog/subjects/<id>.json. If this fails, the Go reader is
// out of sync with the Rust writer (a Step 8 contract violation).
func TestSubjectManifest_RoundTripJSON(t *testing.T) {
src := `{
"schema": "subject_manifest.v1",
"candidate_id": "WORKER-1",
"created_at": "2026-05-03T08:22:24.571647177Z",
"updated_at": "2026-05-03T08:22:24.571647177Z",
"status": "active",
"vertical": "unknown",
"consent": {
"general_pii": {
"status": "pending_backfill_review",
"version": ""
},
"biometric": {
"status": "never_collected"
}
},
"retention": {
"general_pii_until": "2030-05-02T08:22:24.571647177Z",
"policy": "4_year_default"
},
"datasets": [
{"name": "workers_500k", "key_column": "worker_id", "key_value": "1"}
],
"safe_views": ["workers_safe"],
"audit_log_path": "_catalog/subjects/WORKER-1.audit.jsonl",
"audit_log_chain_root": ""
}`
var m SubjectManifest
if err := json.Unmarshal([]byte(src), &m); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if m.CandidateID != "WORKER-1" {
t.Fatalf("candidate_id wrong: %s", m.CandidateID)
}
if m.Status != "active" {
t.Fatalf("status wrong: %s", m.Status)
}
if m.Consent.GeneralPii.Status != "pending_backfill_review" {
t.Fatalf("general_pii.status wrong: %s", m.Consent.GeneralPii.Status)
}
if m.Consent.Biometric.Status != "never_collected" {
t.Fatalf("biometric.status wrong: %s", m.Consent.Biometric.Status)
}
if m.Retention.Policy != "4_year_default" {
t.Fatalf("retention.policy wrong: %s", m.Retention.Policy)
}
if len(m.Datasets) != 1 || m.Datasets[0].Name != "workers_500k" {
t.Fatalf("datasets wrong: %+v", m.Datasets)
}
}
+7 -1
View File
@@ -125,9 +125,15 @@ const (
// Provenance is the source-linkage every distillation record carries.
// SourceFile is required (no record without source linkage); other
// fields are best-effort for de-duplication and trace-back.
//
// LineOffset is intentionally NOT `omitempty`: line offset 0 is the
// first row of a source file — a real, semantically-meaningful value.
// Stripping it on first-row records would silently diverge from the
// Bun materializer (which always emits the field). Surfaced by the
// 2026-05-02 cross-runtime parity probe.
type Provenance struct {
SourceFile string `json:"source_file"`
LineOffset int64 `json:"line_offset,omitempty"`
LineOffset int64 `json:"line_offset"`
SigHash string `json:"sig_hash"`
RecordedAt string `json:"recorded_at"` // ISO 8601
}
+17
View File
@@ -165,6 +165,12 @@ type ValidatordConfig struct {
DefaultMaxTokens int `toml:"default_max_tokens"`
// Per-call timeout for the chat hop in seconds. 0 = 240s.
ChatTimeoutSecs int `toml:"chat_timeout_secs"`
// SessionLogPath: where to append SessionRecord JSONL rows for
// offline analysis (DuckDB queries, scrum review tooling). Empty
// = disabled. Production sets a stable path under
// /var/lib/lakehouse/validator/sessions.jsonl. Append-only,
// best-effort; see internal/validator/session_log.go.
SessionLogPath string `toml:"session_log_path"`
}
// ObserverdConfig drives the observer service (cmd/observerd).
@@ -240,6 +246,13 @@ type LogConfig struct {
// the corpus-downgrade rule: it's already weak, no need to downgrade
// further. Strong (paid / cloud / frontier) models trigger the gate.
type ModelsConfig struct {
// ⚠ UNWIRED scaffolding — these tier fields parse from TOML and
// Resolve() reads them, but as of 2026-05-03 NO OTHER CODE calls
// Resolve(). Setting these in lakehouse.toml has no runtime effect
// on routing. Customer hot path uses local Ollama via direct model
// name per PRD line 70 (everything runs locally). If you wire up
// Resolve() consumers, document them here so the next person knows
// the field is live, not dead scaffolding.
LocalFast string `toml:"local_fast"`
LocalEmbed string `toml:"local_embed"`
LocalJudge string `toml:"local_judge"`
@@ -254,6 +267,9 @@ type ModelsConfig struct {
FrontierStrong string `toml:"frontier_strong"`
FrontierFree string `toml:"frontier_free"`
// WeakModels IS live — internal/workflow/modes.go reads it at
// startup, internal/matrix/downgrade.go uses the list for the
// strong-model auto-downgrade gate. Don't lump with unwired fields.
WeakModels []string `toml:"weak_models"`
}
@@ -393,6 +409,7 @@ func DefaultConfig() Config {
DefaultMaxIterations: 3,
DefaultMaxTokens: 4096,
ChatTimeoutSecs: 240,
SessionLogPath: "", // empty = no session JSONL. Operators set under /var/lib/lakehouse/validator/sessions.jsonl.
},
Chatd: ChatdConfig{
Bind: "127.0.0.1:3220",
+46 -3
View File
@@ -1,6 +1,7 @@
package shared
import (
"context"
"net/http"
"os"
"time"
@@ -8,6 +9,30 @@ import (
"git.agentview.dev/profit/golangLAKEHOUSE/internal/langfuse"
)
// TraceIDHeader propagates a Langfuse trace id across services. When
// validatord makes a /v1/iterate call that internally calls chatd's
// /v1/chat, validatord sends this header so both daemons' middleware
// emit spans under the SAME trace tree (rather than two unrelated
// traces). Closes the multi-call observability gap J flagged
// 2026-05-02 ("we need to make sure they have the corpus of
// information to complete the process and we want to spot errors").
const TraceIDHeader = "X-Lakehouse-Trace-Id"
// traceIDCtxKey is the context value key for the per-request trace id.
// Handlers downstream of langfuseMiddleware can pull it via TraceIDFromCtx
// to attach child spans (e.g. iteration-attempt spans inside validatord).
type traceIDCtxKey struct{}
// TraceIDFromCtx returns the per-request Langfuse trace id, or "" if
// the middleware didn't set one (Langfuse not configured / /health
// bypass / no Client wired).
func TraceIDFromCtx(ctx context.Context) string {
if v, ok := ctx.Value(traceIDCtxKey{}).(string); ok {
return v
}
return ""
}
// langfuseMiddleware emits one Langfuse trace per HTTP request, with
// a single span carrying start/end timestamps + status code. Per
// OPEN item #2 (closed by the wave that adds this file): production
@@ -36,7 +61,14 @@ func langfuseMiddleware(serviceName string, lf *langfuse.Client) func(http.Handl
start := time.Now()
sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
traceID := lf.Trace(r.Context(), langfuse.TraceInput{
// If the caller forwarded a trace id (cross-service parent
// linkage) reuse it instead of starting a new trace. Spans
// from this service then attach to the parent trace tree
// so an /v1/iterate session shows as one trace with
// children for each /v1/chat hop.
traceID := r.Header.Get(TraceIDHeader)
if traceID == "" {
traceID = lf.Trace(r.Context(), langfuse.TraceInput{
Name: serviceName + " " + r.Method + " " + r.URL.Path,
Tags: []string{serviceName, r.Method},
Metadata: map[string]any{
@@ -45,6 +77,12 @@ func langfuseMiddleware(serviceName string, lf *langfuse.Client) func(http.Handl
"remote_addr": r.RemoteAddr,
},
})
}
// Stash the trace id on the request context so downstream
// handlers can attach finer-grained spans (e.g. one per
// iteration attempt inside validator.Iterate).
r = r.WithContext(context.WithValue(r.Context(), traceIDCtxKey{}, traceID))
next.ServeHTTP(sw, r)
@@ -89,13 +127,18 @@ func (sw *statusWriter) WriteHeader(code int) {
sw.ResponseWriter.WriteHeader(code)
}
// loadLangfuseFromEnv builds a langfuse.Client from environment
// LoadLangfuseFromEnv builds a langfuse.Client from environment
// variables. Returns nil if any of LANGFUSE_URL / LANGFUSE_PUBLIC_KEY
// / LANGFUSE_SECRET_KEY is unset (best-effort: missing config means
// no tracing, never a startup error). Same env names as the bare
// /etc/lakehouse/langfuse.env file used by the multi_coord_stress
// driver — operators ship one env file across every daemon.
func loadLangfuseFromEnv() *langfuse.Client {
//
// Exported 2026-05-02 so daemons that need to emit application-level
// child spans (validatord's iterate-attempt spans) can hold their own
// reference to the same client `shared.Run` is already wiring into
// the middleware.
func LoadLangfuseFromEnv() *langfuse.Client {
url := os.Getenv("LANGFUSE_URL")
pk := os.Getenv("LANGFUSE_PUBLIC_KEY")
sk := os.Getenv("LANGFUSE_SECRET_KEY")
@@ -119,6 +119,50 @@ func TestLangfuseMiddleware_RealRequestEmitted(t *testing.T) {
}
}
// TestLangfuseMiddleware_HonorsTraceIDHeader locks the cross-service
// trace-linkage contract: when validatord forwards a /v1/iterate's
// trace id to chatd via X-Lakehouse-Trace-Id, chatd's middleware
// MUST reuse that id rather than minting a new one. Without this,
// every chat hop spawned its own orphan trace and an iterate session
// with N retries showed in Langfuse as N+1 disconnected traces.
//
// We verify by inspecting the trace id stashed on the request
// context — handlers downstream pull it via TraceIDFromCtx and
// attach finer-grained spans to it.
func TestLangfuseMiddleware_HonorsTraceIDHeader(t *testing.T) {
// nil client path is fine — TraceIDFromCtx still propagates the
// header value because the middleware sets the ctx value before
// minting any new id. But that's only true when the client is
// non-nil (the nil-client branch is a passthrough that never sets
// the ctx value). So we wire a real (mocked) Langfuse to exercise
// the header-honoring branch.
lfMock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer lfMock.Close()
lf := langfuse.New(lfMock.URL, "test-pk", "test-sk", nil)
mw := langfuseMiddleware("test-service", lf)
var observed string
h := mw(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
observed = TraceIDFromCtx(r.Context())
}))
srv := httptest.NewServer(h)
defer srv.Close()
req, _ := http.NewRequest("POST", srv.URL+"/api/echo", nil)
req.Header.Set(TraceIDHeader, "trace-from-parent")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("POST: %v", err)
}
resp.Body.Close()
if observed != "trace-from-parent" {
t.Errorf("expected forwarded trace id, got %q", observed)
}
}
// TestLangfuseMiddleware_StatusCaptured locks the status-writer
// wrapping: when the handler returns 500, the middleware must see
// 500 in the span output (otherwise error traces all show 200 and
+1 -1
View File
@@ -80,7 +80,7 @@ func Run(serviceName, addr string, register RegisterRoutes, auth AuthConfig) err
// gets free production-traffic trace visibility when those env
// vars are set. Missing any of the three → nil client → middleware
// becomes a passthrough.
lf := loadLangfuseFromEnv()
lf := LoadLangfuseFromEnv()
if lf != nil {
// Make sure pending events flush on graceful shutdown so the
// last few requests' traces don't get lost.
+87 -19
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
)
// IterateRequest is the input to Iterate. Mirrors Rust's
@@ -20,14 +21,24 @@ type IterateRequest struct {
MaxIterations int `json:"max_iterations,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
// TraceID lets external schedulers force a parent trace id (so the
// iterate session lands as a child of an upstream Langfuse trace).
// Empty = the caller didn't provide one; validatord may still
// generate one from request context.
TraceID string `json:"trace_id,omitempty"`
}
// IterateAttempt is one row in the history. raw is capped at 2000
// chars on the wire to keep responses bounded.
// chars on the wire to keep responses bounded. SpanID points at the
// Langfuse span emitted for this attempt (if a Tracer was wired in
// IterateConfig); empty when tracing was disabled. Operators reading
// `data/_kb/replay_runs.jsonl` or staffing-co-pilot session logs can
// jump from a row to its full trace tree via this id.
type IterateAttempt struct {
Iteration int `json:"iteration"`
Raw string `json:"raw"`
Status AttemptStatus `json:"status"`
SpanID string `json:"span_id,omitempty"`
}
// AttemptStatus is the per-attempt verdict. Tagged JSON so consumers
@@ -38,11 +49,14 @@ type AttemptStatus struct {
}
// IterateResponse is the success payload (200 + Report + accepted artifact).
// TraceID echoes back the parent trace id so callers can pivot to
// Langfuse and see the full session (chat hops + validator verdicts).
type IterateResponse struct {
Artifact map[string]any `json:"artifact"`
Validation Report `json:"validation"`
Iterations int `json:"iterations"`
History []IterateAttempt `json:"history"`
TraceID string `json:"trace_id,omitempty"`
}
// IterateFailure is the max-iter-exhausted payload (422 + history).
@@ -50,19 +64,53 @@ type IterateFailure struct {
Error string `json:"error"`
Iterations int `json:"iterations"`
History []IterateAttempt `json:"history"`
TraceID string `json:"trace_id,omitempty"`
}
// ChatCaller is the seam Iterate uses to invoke an LLM. Tests inject
// scripted callers; production wires this to the chatd /v1/chat HTTP
// endpoint. Implementations must return the model's textual content
// (no choices wrapper, no message envelope).
type ChatCaller func(ctx context.Context, system, user, provider, model string, temperature *float64, maxTokens int) (string, error)
// (no choices wrapper, no message envelope). traceID is the Langfuse
// parent trace id — HTTP transports forward it as the
// `X-Lakehouse-Trace-Id` header so chatd's middleware attaches its
// spans to the same trace.
type ChatCaller func(ctx context.Context, system, user, provider, model string, temperature *float64, maxTokens int, traceID string) (string, error)
// AttemptSpan describes one iteration's chat-call + validator-verdict.
// The Tracer (see IterateConfig) consumes these to emit Langfuse
// spans — one span per attempt, plus inputs/outputs so an operator
// scrolling Langfuse sees the full prompt → raw → verdict chain
// without having to join multiple JSONL files.
type AttemptSpan struct {
TraceID string
ParentID string // span id of the surrounding /v1/iterate http request, if any
Iteration int
Model string
Provider string
Prompt string
Raw string
Verdict AttemptStatus
StartTime time.Time
EndTime time.Time
}
// Tracer is the hook Iterate calls after each attempt. Returns the
// span id so it lands in IterateAttempt.SpanID. nil = tracing
// disabled (tests, or production with Langfuse unconfigured).
//
// Production wiring lives in cmd/validatord/main.go and routes to
// internal/langfuse.Client — kept as an interface here so the
// validator package doesn't depend on langfuse for unit tests.
type Tracer func(span AttemptSpan) string
// IterateConfig threads daemon-level settings into the orchestrator.
type IterateConfig struct {
DefaultMaxIterations int
DefaultMaxTokens int
DefaultTemperature float64
// Tracer (optional) emits one span per iteration attempt for
// per-call visibility. nil = no tracing.
Tracer Tracer
}
const (
@@ -101,36 +149,59 @@ func Iterate(ctx context.Context, req IterateRequest, cfg IterateConfig, chat Ch
currentPrompt := req.Prompt
history := make([]IterateAttempt, 0, maxIter)
traceID := req.TraceID
// recordAttempt: append to history, emit a Langfuse span if a
// Tracer is wired, return the SpanID. Centralized so every code
// path (no_json, accepted, validation_failed) hits the same
// observability path — no easy way to forget tracing on a branch.
recordAttempt := func(iteration int, raw string, verdict AttemptStatus, started time.Time) string {
spanID := ""
if cfg.Tracer != nil && traceID != "" {
spanID = cfg.Tracer(AttemptSpan{
TraceID: traceID,
Iteration: iteration,
Model: req.Model,
Provider: req.Provider,
Prompt: currentPrompt,
Raw: raw,
Verdict: verdict,
StartTime: started,
EndTime: time.Now(),
})
}
history = append(history, IterateAttempt{
Iteration: iteration,
Raw: trim(raw, 2000),
Status: verdict,
SpanID: spanID,
})
return spanID
}
for i := 0; i < maxIter; i++ {
raw, err := chat(ctx, req.System, currentPrompt, req.Provider, req.Model, temp, maxTokens)
started := time.Now()
raw, err := chat(ctx, req.System, currentPrompt, req.Provider, req.Model, temp, maxTokens, traceID)
if err != nil {
return nil, nil, fmt.Errorf("/v1/chat hop failed at iter %d: %w", i, err)
}
artifact := ExtractJSON(raw)
if artifact == nil {
history = append(history, IterateAttempt{
Iteration: i,
Raw: trim(raw, 2000),
Status: AttemptStatus{Kind: "no_json"},
})
recordAttempt(i, raw, AttemptStatus{Kind: "no_json"}, started)
currentPrompt = req.Prompt + "\n\nYour previous attempt did not contain a JSON object. Reply with ONLY a valid JSON object matching the requested artifact shape."
continue
}
report, vErr := validate(req.Kind, artifact)
if vErr == nil {
history = append(history, IterateAttempt{
Iteration: i,
Raw: trim(raw, 2000),
Status: AttemptStatus{Kind: "accepted"},
})
recordAttempt(i, raw, AttemptStatus{Kind: "accepted"}, started)
return &IterateResponse{
Artifact: artifact,
Validation: report,
Iterations: i + 1,
History: history,
TraceID: traceID,
}, nil, nil
}
@@ -138,11 +209,7 @@ func Iterate(ctx context.Context, req IterateRequest, cfg IterateConfig, chat Ch
// The model sees concrete failure mode + retries with corrective
// context. Same "validator IS the observer" shape as Phase 43.
errSummary := vErr.Error()
history = append(history, IterateAttempt{
Iteration: i,
Raw: trim(raw, 2000),
Status: AttemptStatus{Kind: "validation_failed", Error: errSummary},
})
recordAttempt(i, raw, AttemptStatus{Kind: "validation_failed", Error: errSummary}, started)
currentPrompt = req.Prompt + "\n\nPrior attempt failed validation:\n" + errSummary + "\n\nFix the specific issue above and respond with a corrected JSON object."
}
@@ -150,6 +217,7 @@ func Iterate(ctx context.Context, req IterateRequest, cfg IterateConfig, chat Ch
Error: fmt.Sprintf("max iterations reached (%d) without passing validation", maxIter),
Iterations: maxIter,
History: history,
TraceID: traceID,
}, nil
}
+91 -2
View File
@@ -3,6 +3,7 @@ package validator
import (
"context"
"errors"
"fmt"
"testing"
)
@@ -69,7 +70,7 @@ func TestExtractJSON_TopLevelArrayReturnsFirstInnerObject(t *testing.T) {
func scriptedChat(responses ...string) (ChatCaller, *int) {
idx := 0
return func(_ context.Context, _, _ string, _, _ string, _ *float64, _ int) (string, error) {
return func(_ context.Context, _, _ string, _, _ string, _ *float64, _ int, _ string) (string, error) {
if idx >= len(responses) {
return "", errors.New("scripted chat exhausted")
}
@@ -175,8 +176,96 @@ func TestIterate_MaxIterationsExhaustedReturnsFailure(t *testing.T) {
}
}
// TestIterate_TracerEmitsSpanPerAttempt locks the per-attempt
// observability contract: when a Tracer is wired AND a TraceID is
// present, every retry produces exactly one span and the SpanID
// lands on the IterateAttempt history row.
func TestIterate_TracerEmitsSpanPerAttempt(t *testing.T) {
chat, _ := scriptedChat(
"no json",
`{"k":"v"}`,
)
validate := func(_ string, _ map[string]any) (Report, error) { return Report{}, nil }
var spans []AttemptSpan
tracer := func(s AttemptSpan) string {
spans = append(spans, s)
return fmt.Sprintf("span-%d", s.Iteration)
}
resp, _, err := Iterate(context.Background(),
IterateRequest{Kind: "playbook", Prompt: "X", Provider: "ollama", Model: "qwen", TraceID: "trace-abc"},
IterateConfig{Tracer: tracer}, chat, validate)
if err != nil {
t.Fatalf("Iterate: %v", err)
}
if resp.TraceID != "trace-abc" {
t.Errorf("TraceID echo: got %q, want trace-abc", resp.TraceID)
}
if len(spans) != 2 {
t.Fatalf("expected 2 emitted spans, got %d", len(spans))
}
if spans[0].Verdict.Kind != "no_json" || spans[1].Verdict.Kind != "accepted" {
t.Errorf("verdict propagation: %+v", spans)
}
if resp.History[0].SpanID != "span-0" || resp.History[1].SpanID != "span-1" {
t.Errorf("SpanID stamping: %+v", resp.History)
}
if spans[0].TraceID != "trace-abc" {
t.Errorf("TraceID didn't reach the tracer: %q", spans[0].TraceID)
}
}
// TestIterate_NoTraceIDSkipsTracer guards the "Langfuse not configured"
// path: tracer is non-nil but trace_id is empty → we MUST NOT emit
// orphan spans.
func TestIterate_NoTraceIDSkipsTracer(t *testing.T) {
chat, _ := scriptedChat(`{"k":"v"}`)
validate := func(_ string, _ map[string]any) (Report, error) { return Report{}, nil }
called := 0
tracer := func(_ AttemptSpan) string {
called++
return "should-not-be-used"
}
resp, _, err := Iterate(context.Background(),
IterateRequest{Kind: "playbook", Prompt: "X", Provider: "ollama", Model: "qwen"}, // no TraceID
IterateConfig{Tracer: tracer}, chat, validate)
if err != nil {
t.Fatalf("Iterate: %v", err)
}
if called != 0 {
t.Errorf("tracer should not fire without a trace_id; called=%d", called)
}
if resp.History[0].SpanID != "" {
t.Errorf("SpanID should be empty when no trace_id: %q", resp.History[0].SpanID)
}
}
// TestIterate_ChatCallerReceivesTraceID confirms the trace_id is
// forwarded into the ChatCaller signature so HTTP transports can set
// the X-Lakehouse-Trace-Id header. Without this, chatd's Langfuse
// emit would create an orphan trace per retry instead of nesting
// under the iterate parent.
func TestIterate_ChatCallerReceivesTraceID(t *testing.T) {
var observedTrace string
chat := func(_ context.Context, _, _ string, _, _ string, _ *float64, _ int, traceID string) (string, error) {
observedTrace = traceID
return `{"k":"v"}`, nil
}
validate := func(_ string, _ map[string]any) (Report, error) { return Report{}, nil }
_, _, err := Iterate(context.Background(),
IterateRequest{Kind: "playbook", Prompt: "X", Provider: "ollama", Model: "x", TraceID: "trace-xyz"},
IterateConfig{}, chat, validate)
if err != nil {
t.Fatalf("Iterate: %v", err)
}
if observedTrace != "trace-xyz" {
t.Errorf("ChatCaller should receive trace_id; got %q", observedTrace)
}
}
func TestIterate_PropagatesChatInfraError(t *testing.T) {
chat := func(_ context.Context, _, _ string, _, _ string, _ *float64, _ int) (string, error) {
chat := func(_ context.Context, _, _ string, _, _ string, _ *float64, _ int, _ string) (string, error) {
return "", errors.New("connection refused")
}
validate := func(_ string, _ map[string]any) (Report, error) { return Report{}, nil }
+199
View File
@@ -0,0 +1,199 @@
package validator
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
)
// SessionRecordSchema versions the on-wire JSON shape. Bump when the
// schema changes incompatibly. Consumers (e.g. duckdb queries over
// coordinator_sessions.jsonl, scrum review tooling) check this field
// to decide whether they understand the row.
const SessionRecordSchema = "session.iterate.v1"
// SessionRecord is one row in coordinator_sessions.jsonl. Captures the
// full retry chain of a single /v1/iterate session for offline
// forensics: "show me all sessions where the validator caught a
// phantom worker" / "show me all sessions where retrieval missed."
//
// The Langfuse trace tree (see X-Lakehouse-Trace-Id propagation
// 2026-05-02) is the live view; this JSONL is the longitudinal view
// for ad-hoc DuckDB queries over thousands of sessions.
type SessionRecord struct {
Schema string `json:"schema"`
SessionID string `json:"session_id"` // = Langfuse trace_id
Timestamp string `json:"timestamp"` // ISO 8601
Daemon string `json:"daemon"`
Kind string `json:"kind"` // fill | email | playbook
Model string `json:"model"`
Provider string `json:"provider"`
Prompt string `json:"prompt"` // truncated to 4000
Iterations int `json:"iterations"`
MaxIterations int `json:"max_iterations"`
FinalVerdict string `json:"final_verdict"` // accepted | max_iter_exhausted | infra_error
Attempts []SessionAttemptRecord `json:"attempts"`
Artifact map[string]any `json:"artifact,omitempty"` // present on success
GroundedInRoster *bool `json:"grounded_in_roster,omitempty"`
DurationMs int64 `json:"duration_ms"`
}
// SessionAttemptRecord mirrors IterateAttempt but stores only the
// stable signals (iteration, verdict, error, span id). The raw model
// output is intentionally NOT captured here — it lives in the
// Langfuse span (queryable by span_id) and the iterate response. Two
// copies would let the JSONL grow unbounded on long sessions.
type SessionAttemptRecord struct {
Iteration int `json:"iteration"`
VerdictKind string `json:"verdict_kind"` // no_json | validation_failed | accepted
Error string `json:"error,omitempty"`
SpanID string `json:"span_id,omitempty"`
}
// SessionLogger appends SessionRecord rows to a JSONL file. Best-effort:
// errors are logged via slog and never returned to the caller (per the
// rest of the observability stack — never block a request because the
// session log is unhappy).
//
// nil is a valid value: NewSessionLogger("") returns nil; Append on
// a nil receiver is a no-op. Lets validatord skip the wiring entirely
// when no session log is configured.
type SessionLogger struct {
path string
mu sync.Mutex
}
// NewSessionLogger constructs a logger writing to `path`. Empty path
// disables logging. Creates parent dirs lazily on first write so the
// constructor doesn't panic when the path is on a not-yet-mounted
// volume (e.g. systemd unit ordering).
func NewSessionLogger(path string) *SessionLogger {
if path == "" {
return nil
}
return &SessionLogger{path: path}
}
// Append writes one JSONL row. Returns nil on success or after a
// best-effort failure. The caller doesn't need to handle errors;
// logging is an observability witness, not a correctness gate.
func (l *SessionLogger) Append(rec SessionRecord) {
if l == nil {
return
}
if rec.Schema == "" {
rec.Schema = SessionRecordSchema
}
if rec.Timestamp == "" {
rec.Timestamp = time.Now().UTC().Format(time.RFC3339Nano)
}
if rec.Daemon == "" {
rec.Daemon = "validatord"
}
body, err := json.Marshal(rec)
if err != nil {
slog.Warn("session_log: marshal", "err", err, "session_id", rec.SessionID)
return
}
body = append(body, '\n')
l.mu.Lock()
defer l.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil {
slog.Warn("session_log: mkdir", "err", err, "path", l.path)
return
}
f, err := os.OpenFile(l.path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
slog.Warn("session_log: open", "err", err, "path", l.path)
return
}
defer f.Close()
if _, err := f.Write(body); err != nil {
slog.Warn("session_log: write", "err", err, "session_id", rec.SessionID)
}
}
// BuildSessionRecord assembles a SessionRecord from an iterate
// response/failure pair. Exactly one of resp/fail must be non-nil
// (or both nil for an infrastructure failure). Centralized here so
// validatord and any future iterate-using daemon emit the same shape.
//
// rosterCheck is called per accepted artifact to populate
// GroundedInRoster — set to nil to skip the check (e.g. for
// non-fill kinds that don't have worker IDs). Returns the shaped
// record; pass to logger.Append on the caller side so failed
// daemons don't block on their own observability layer.
func BuildSessionRecord(
req IterateRequest,
resp *IterateResponse,
fail *IterateFailure,
infraErr error,
rosterCheck func(map[string]any) *bool,
durationMs int64,
) SessionRecord {
rec := SessionRecord{
SessionID: req.TraceID,
Kind: req.Kind,
Model: req.Model,
Provider: req.Provider,
Prompt: trim(req.Prompt, 4000),
MaxIterations: req.MaxIterations,
DurationMs: durationMs,
}
switch {
case resp != nil:
rec.Iterations = resp.Iterations
rec.FinalVerdict = "accepted"
rec.Attempts = sessionAttemptsFromHistory(resp.History)
rec.Artifact = resp.Artifact
if rosterCheck != nil {
rec.GroundedInRoster = rosterCheck(resp.Artifact)
}
// Keep the trace id authoritative even if the request didn't
// supply one — the iterate response carries the resolved id.
if rec.SessionID == "" {
rec.SessionID = resp.TraceID
}
case fail != nil:
rec.Iterations = fail.Iterations
rec.FinalVerdict = "max_iter_exhausted"
rec.Attempts = sessionAttemptsFromHistory(fail.History)
if rec.SessionID == "" {
rec.SessionID = fail.TraceID
}
default:
// Infrastructure failure (chat hop crashed mid-loop, etc.).
// We still emit a row so the failure is forensically visible —
// otherwise long debugging sessions get harder.
rec.FinalVerdict = "infra_error"
if infraErr != nil {
rec.Attempts = []SessionAttemptRecord{{
Iteration: 0,
VerdictKind: "infra_error",
Error: trim(fmt.Sprintf("%v", infraErr), 800),
}}
}
}
return rec
}
func sessionAttemptsFromHistory(h []IterateAttempt) []SessionAttemptRecord {
out := make([]SessionAttemptRecord, len(h))
for i, a := range h {
out[i] = SessionAttemptRecord{
Iteration: a.Iteration,
VerdictKind: a.Status.Kind,
Error: a.Status.Error,
SpanID: a.SpanID,
}
}
return out
}
+224
View File
@@ -0,0 +1,224 @@
package validator
import (
"bufio"
"encoding/json"
"os"
"path/filepath"
"sync"
"testing"
)
func TestSessionLogger_AppendsSchemaTaggedRows(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sessions.jsonl")
logger := NewSessionLogger(path)
if logger == nil {
t.Fatal("expected non-nil logger for a non-empty path")
}
rec := SessionRecord{
SessionID: "trace-1",
Kind: "fill",
Model: "qwen3.5:latest",
Provider: "ollama",
Prompt: "produce a fill artifact",
Iterations: 1,
FinalVerdict: "accepted",
}
logger.Append(rec)
rows := readJSONL(t, path)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0]["schema"] != SessionRecordSchema {
t.Errorf("schema autopopulation: %v", rows[0]["schema"])
}
if rows[0]["session_id"] != "trace-1" {
t.Errorf("session_id round-trip: %v", rows[0]["session_id"])
}
if rows[0]["timestamp"] == "" || rows[0]["timestamp"] == nil {
t.Errorf("timestamp autopopulation: %v", rows[0]["timestamp"])
}
if rows[0]["daemon"] != "validatord" {
t.Errorf("daemon autopopulation: %v", rows[0]["daemon"])
}
}
func TestSessionLogger_NilIsNoOp(t *testing.T) {
var logger *SessionLogger // nil
logger.Append(SessionRecord{SessionID: "trace-1"})
// No panic, no error — the test passing IS the assertion.
}
func TestSessionLogger_EmptyPathReturnsNil(t *testing.T) {
if NewSessionLogger("") != nil {
t.Error("empty path should disable logging (nil logger)")
}
}
// TestSessionLogger_ConcurrentAppends locks the per-row atomicity: N
// goroutines each appending must produce N well-formed JSONL rows
// with no torn writes. Mutex lives at the logger level, not OS-level,
// so this also documents that concurrent logger instances would NOT
// be safe (one logger per daemon — that's fine).
func TestSessionLogger_ConcurrentAppends(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "sessions.jsonl")
logger := NewSessionLogger(path)
const N = 64
var wg sync.WaitGroup
for i := 0; i < N; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
logger.Append(SessionRecord{
SessionID: "trace-" + string(rune('a'+i%26)),
Kind: "fill",
FinalVerdict: "accepted",
Iterations: i,
})
}(i)
}
wg.Wait()
rows := readJSONL(t, path)
if len(rows) != N {
t.Fatalf("expected %d rows after concurrent appends, got %d", N, len(rows))
}
// Every row must be parseable JSON — a torn write would throw
// a decode error, which readJSONL t.Fatal's on.
}
func TestBuildSessionRecord_SuccessPathCapturesArtifact(t *testing.T) {
req := IterateRequest{
TraceID: "trace-x",
Kind: "fill",
Prompt: "produce fill",
Provider: "ollama",
Model: "qwen3.5:latest",
MaxIterations: 3,
}
resp := &IterateResponse{
Artifact: map[string]any{"fills": []any{
map[string]any{"candidate_id": "W-1"},
}},
Validation: Report{},
Iterations: 1,
History: []IterateAttempt{{
Iteration: 0,
Raw: `{"fills":[{"candidate_id":"W-1"}]}`,
Status: AttemptStatus{Kind: "accepted"},
SpanID: "span-0",
}},
TraceID: "trace-x",
}
roster := func(_ map[string]any) *bool {
t := true
return &t
}
rec := BuildSessionRecord(req, resp, nil, nil, roster, 1234)
if rec.FinalVerdict != "accepted" || rec.Iterations != 1 {
t.Errorf("verdict/iterations: %+v", rec)
}
if rec.GroundedInRoster == nil || !*rec.GroundedInRoster {
t.Errorf("grounded_in_roster: expected true, got %v", rec.GroundedInRoster)
}
if rec.Artifact == nil {
t.Errorf("artifact missing on success path")
}
if len(rec.Attempts) != 1 || rec.Attempts[0].SpanID != "span-0" {
t.Errorf("attempts shape: %+v", rec.Attempts)
}
if rec.SessionID != "trace-x" {
t.Errorf("session_id: %s", rec.SessionID)
}
if rec.DurationMs != 1234 {
t.Errorf("duration: %d", rec.DurationMs)
}
}
func TestBuildSessionRecord_FailurePathOmitsArtifact(t *testing.T) {
req := IterateRequest{
TraceID: "trace-y",
Kind: "fill",
Provider: "ollama",
Model: "qwen",
MaxIterations: 3,
}
fail := &IterateFailure{
Error: "max iterations reached (3)",
Iterations: 3,
History: []IterateAttempt{
{Iteration: 0, Status: AttemptStatus{Kind: "no_json"}},
{Iteration: 1, Status: AttemptStatus{Kind: "validation_failed", Error: "phantom W-X"}},
{Iteration: 2, Status: AttemptStatus{Kind: "validation_failed", Error: "phantom W-Y"}},
},
TraceID: "trace-y",
}
rec := BuildSessionRecord(req, nil, fail, nil, nil, 8765)
if rec.FinalVerdict != "max_iter_exhausted" {
t.Errorf("final_verdict: %s", rec.FinalVerdict)
}
if rec.Artifact != nil {
t.Errorf("artifact must be omitted on failure path: %v", rec.Artifact)
}
if rec.GroundedInRoster != nil {
t.Errorf("grounded_in_roster should be nil on failure: %v", *rec.GroundedInRoster)
}
if len(rec.Attempts) != 3 || rec.Attempts[2].VerdictKind != "validation_failed" {
t.Errorf("attempts: %+v", rec.Attempts)
}
}
func TestBuildSessionRecord_InfraErrorEmitsRow(t *testing.T) {
// An infrastructure failure (e.g. chat hop crashed) must STILL
// produce a session row — otherwise debugging gets harder
// (no log → operator can't see it failed at all).
req := IterateRequest{TraceID: "trace-z", Kind: "fill"}
rec := BuildSessionRecord(req, nil, nil, errExample{"chat refused"}, nil, 50)
if rec.FinalVerdict != "infra_error" {
t.Errorf("verdict: %s", rec.FinalVerdict)
}
if len(rec.Attempts) != 1 || rec.Attempts[0].VerdictKind != "infra_error" {
t.Errorf("attempts: %+v", rec.Attempts)
}
if rec.Attempts[0].Error == "" {
t.Errorf("error must be carried")
}
}
// ─── Helpers ───────────────────────────────────────────────────
type errExample struct{ msg string }
func (e errExample) Error() string { return e.msg }
func readJSONL(t *testing.T, path string) []map[string]any {
t.Helper()
f, err := os.Open(path)
if err != nil {
t.Fatalf("open: %v", err)
}
defer f.Close()
var out []map[string]any
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 1<<16), 1<<24)
for sc.Scan() {
line := sc.Bytes()
if len(line) == 0 {
continue
}
var row map[string]any
if err := json.Unmarshal(line, &row); err != nil {
t.Fatalf("parse line %q: %v", line, err)
}
out = append(out, row)
}
if err := sc.Err(); err != nil {
t.Fatalf("scan: %v", err)
}
return out
}
+93 -1
View File
@@ -19,7 +19,11 @@
package validator
import "time"
import (
"encoding/json"
"fmt"
"time"
)
// Artifact is the discriminated union of input shapes a Validator
// can receive. Mirrors Rust's `enum Artifact`. The first non-zero
@@ -107,6 +111,94 @@ func (e *ValidationError) Error() string {
return string(e.Kind) + ": " + e.Reason
}
// MarshalJSON emits the Rust-compatible serde-tagged-enum shape:
//
// {"Schema": {"field": "...", "reason": "..."}}
// {"Completeness": {"reason": "..."}}
// {"Consistency": {"reason": "..."}}
// {"Policy": {"reason": "..."}}
//
// Aligns with `crates/validator/src/lib.rs::ValidationError` so callers
// that parse the error envelope work across runtimes. Surfaced by the
// 2026-05-02 cross-runtime parity probe (validator_parity.sh) which
// found 5/6 body shapes diverging on identical input.
func (e ValidationError) MarshalJSON() ([]byte, error) {
variant := variantName(e.Kind)
inner := map[string]string{}
if e.Kind == ErrSchema {
inner["field"] = e.Field
}
inner["reason"] = e.Reason
return json.Marshal(map[string]any{variant: inner})
}
// UnmarshalJSON accepts BOTH the Rust serde-tagged shape AND the
// legacy flat-struct shape Go used to emit, so existing on-disk JSONL
// rows still parse. New writes always use the Rust shape (per
// MarshalJSON above).
func (e *ValidationError) UnmarshalJSON(data []byte) error {
// Try the Rust serde shape first: {"<Variant>":{...}}
var tagged map[string]map[string]string
if err := json.Unmarshal(data, &tagged); err == nil && len(tagged) == 1 {
for variant, inner := range tagged {
kind, ok := kindFromVariant(variant)
if !ok {
return fmt.Errorf("unknown ValidationError variant %q", variant)
}
e.Kind = kind
e.Field = inner["field"]
e.Reason = inner["reason"]
return nil
}
}
// Fall back to the legacy flat shape: {"Kind":"...","Field":"...","Reason":"..."}
var flat struct {
Kind ValidationErrorKind `json:"Kind"`
Field string `json:"Field"`
Reason string `json:"Reason"`
}
if err := json.Unmarshal(data, &flat); err != nil {
return fmt.Errorf("ValidationError: neither serde nor flat shape: %w", err)
}
if flat.Kind == "" {
return fmt.Errorf("ValidationError: missing kind")
}
e.Kind = flat.Kind
e.Field = flat.Field
e.Reason = flat.Reason
return nil
}
// variantName maps the lowercase Go Kind to the CamelCase Rust serde
// variant name. Kept in sync with `crates/validator/src/lib.rs::ValidationError`.
func variantName(k ValidationErrorKind) string {
switch k {
case ErrSchema:
return "Schema"
case ErrCompleteness:
return "Completeness"
case ErrConsistency:
return "Consistency"
case ErrPolicy:
return "Policy"
}
return string(k) // fallback — preserves Kind for debugging
}
func kindFromVariant(v string) (ValidationErrorKind, bool) {
switch v {
case "Schema":
return ErrSchema, true
case "Completeness":
return ErrCompleteness, true
case "Consistency":
return ErrConsistency, true
case "Policy":
return ErrPolicy, true
}
return "", false
}
// Validator is the interface every validator implements.
// Stateless — construction takes any deps (e.g. WorkerLookup)
// upfront, validate() is pure on its inputs.
+97
View File
@@ -0,0 +1,97 @@
package validator
import (
"encoding/json"
"testing"
)
// TestValidationError_MarshalsRustSerdeShape pins the on-wire JSON
// envelope for cross-runtime parity with crates/validator's
// `enum ValidationError`. Surfaced by the 2026-05-02 validator parity
// probe (5/6 body-shape divergence pre-fix → 6/6 match post-fix).
//
// Failing this test = wire-format drift. Run the parity probe at
// scripts/cutover/parity/validator_parity.sh to confirm Rust is
// the source-of-truth shape this test is anchored to.
func TestValidationError_MarshalsRustSerdeShape(t *testing.T) {
cases := []struct {
name string
err ValidationError
want string
}{
{
name: "schema includes field+reason inside variant body",
err: ValidationError{Kind: ErrSchema, Field: "fingerprint", Reason: "missing"},
want: `{"Schema":{"field":"fingerprint","reason":"missing"}}`,
},
{
name: "completeness only carries reason (no field key)",
err: ValidationError{Kind: ErrCompleteness, Reason: "endorsed_names must be non-empty"},
want: `{"Completeness":{"reason":"endorsed_names must be non-empty"}}`,
},
{
name: "consistency only carries reason",
err: ValidationError{Kind: ErrConsistency, Reason: "candidate_id W-1 not in roster"},
want: `{"Consistency":{"reason":"candidate_id W-1 not in roster"}}`,
},
{
name: "policy only carries reason",
err: ValidationError{Kind: ErrPolicy, Reason: "client C-1 is on candidate's blacklist"},
want: `{"Policy":{"reason":"client C-1 is on candidate's blacklist"}}`,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := json.Marshal(c.err)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if string(got) != c.want {
t.Errorf("\n got: %s\nwant: %s", got, c.want)
}
})
}
}
// TestValidationError_RoundTripsRustShape confirms a marshal+unmarshal
// returns the same fields. Important: a Go side reading a Rust-written
// ValidationError JSONL must populate Kind/Field/Reason correctly.
func TestValidationError_RoundTripsRustShape(t *testing.T) {
original := ValidationError{Kind: ErrSchema, Field: "operation", Reason: "expected fill: prefix"}
body, err := json.Marshal(original)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded ValidationError
if err := json.Unmarshal(body, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.Kind != original.Kind || decoded.Field != original.Field || decoded.Reason != original.Reason {
t.Errorf("round-trip drift:\n in: %+v\n out: %+v", original, decoded)
}
}
// TestValidationError_ParsesLegacyFlatShape ensures consumers reading
// older Go-emitted JSONL still parse. This is the migration path for
// any persisted error rows that pre-date the 2026-05-02 alignment.
func TestValidationError_ParsesLegacyFlatShape(t *testing.T) {
legacy := []byte(`{"Kind":"schema","Field":"x","Reason":"y"}`)
var ve ValidationError
if err := json.Unmarshal(legacy, &ve); err != nil {
t.Fatalf("legacy shape should parse: %v", err)
}
if ve.Kind != ErrSchema || ve.Field != "x" || ve.Reason != "y" {
t.Errorf("legacy parse wrong: %+v", ve)
}
}
// TestValidationError_RejectsUnknownVariant catches drift if Rust adds
// a new variant the Go side hasn't learned about yet — surfaces as a
// parse error rather than a silent default.
func TestValidationError_RejectsUnknownVariant(t *testing.T) {
body := []byte(`{"NewVariant":{"reason":"something"}}`)
var ve ValidationError
if err := json.Unmarshal(body, &ve); err == nil {
t.Errorf("expected error on unknown variant, got %+v", ve)
}
}
+7
View File
@@ -119,6 +119,13 @@ default_max_iterations = 3
default_max_tokens = 4096
# Chat hop timeout (seconds). 240s tolerates frontier reasoning models.
chat_timeout_secs = 240
# Session log: where to append one JSONL row per /v1/iterate session
# for offline DuckDB analysis. Empty = disabled. Production:
# session_log_path = "/var/lib/lakehouse/validator/sessions.jsonl"
# Each row: schema=session.iterate.v1, session_id (= Langfuse trace_id),
# kind, model, iterations, attempts[], final_verdict, grounded_in_roster,
# duration_ms. See internal/validator/session_log.go.
session_log_path = ""
[s3]
endpoint = "http://localhost:9000"
@@ -0,0 +1,17 @@
# /v1/embed cross-runtime parity probe
**Date:** 2026-05-05T08:22:55Z
**Rust:** `http://127.0.0.1:3100/ai/embed` · **Go:** `http://127.0.0.1:4110/v1/embed`
**Model:** `nomic-embed-text` (forced — overrides each side's default)
**Match metric:** cosine similarity ≥ `0.99999`
Identical text → both endpoints. Cosine compares vector
DIRECTION (the operationally-meaningful property for HNSW
retrieval); byte-equal isn't expected because Go round-trips
through float32 internally while Rust stays at f64.
**Tally:** 8 match · 0 diff (out of 8 fixtures)
_Cosine ≥ 0.99999 on every fixture — embed parity holds_
_post-sidecar-drop. Rust and Go produce vectors that point in_
_the same direction in 768-dim space._
@@ -0,0 +1,14 @@
# extract_json parity probe — Rust vs Go
**Date:** 2026-05-05T08:23:07Z
**Rust helper:** `/home/profit/lakehouse/target/release/parity_extract_json` (links live `gateway::v1::iterate::extract_json`)
**Go helper:** `./bin/parity_extract_json_go` (links live `internal/validator.ExtractJSON`)
Identical model-output strings → both runtimes' `extract_json`.
Match = identical `{matched, value}` JSON output.
**Substrate gate:** `cargo test -p gateway extract_json` PASS before probe.
**Tally:** 12 match · 0 diff (out of 12 fixtures)
_No divergences — extract_json parity holds across all fixtures._
@@ -0,0 +1,13 @@
# Materializer parity probe — Bun vs Go
**Date:** 2026-05-05T08:23:08Z
**Bun:** `/home/profit/lakehouse/scripts/distillation/build_evidence_index.ts`
**Go:** `./bin/materializer`
Identical `data/_kb/` source → both runtimes' materializer.
Match = JSONL byte-equal after normalizing `provenance.recorded_at`
(per-run wall clock) + sorted line order (dedup ordering).
**Tally:** 2 match · 0 diff (out of 2 stems)
_No divergences — on-wire JSON parity holds._
@@ -0,0 +1,15 @@
# session_log parity probe — Rust gateway vs Go validatord
**Date:** 2026-05-05T08:23:08Z
**Rust helper:** `/home/profit/lakehouse/target/release/parity_session_log`
**Go helper:** `./bin/parity_session_log_go`
Identical fixture inputs through each runtime's
`SessionRecord` builder + JSON marshaler. Match = byte-equal
after stripping `timestamp` (per-run wall clock) + `daemon`
("gateway" on Rust side, "validatord" on Go side; both are
valid producers in the same longitudinal log).
**Tally:** 4 match · 0 diff (out of 4 fixtures)
_No divergences — schema parity holds across all fixtures._
@@ -0,0 +1,31 @@
# subject_audit_parity
**Generated:** 2026-05-05 08:23:08 UTC
**Spec:** /home/profit/lakehouse/docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §5 Step 8
**Rust helper:** `/home/profit/lakehouse/target/release/parity_subject_audit`
**Go helper:** `./bin/subject_audit_helper`
**Audit dir:** `/home/profit/lakehouse/data/_catalog/subjects`
## Phase 1 — Known-answer vector
Hardcoded fixture row, identical inputs, byte-compare canonical-JSON + HMAC.
**MATCH** ✓
```json
{"mode":"known_answer","canonical":"{\"accessor\":{\"daemon\":\"gateway\",\"kind\":\"gateway_lookup\",\"purpose\":\"parity_test\",\"trace_id\":\"trace-fixed\"},\"candidate_id\":\"WORKER-FIXED\",\"fields_accessed\":[\"name\"],\"prev_chain_hash\":\"GENESIS\",\"result\":\"success\",\"schema\":\"subject_audit.v1\",\"ts\":\"2026-05-03T12:00:00Z\"}","hmac":"f730fa038c847c27386b92eb1939ec64c62086c0a92617ac0bdf9f650c390b96","canonical_bytes_len":266}
```
## Phase 2 — Real production audit logs
Every `*.audit.jsonl` in `/home/profit/lakehouse/data/_catalog/subjects` verified by both runtimes.
**SKIP** — signing key not readable at `/tmp/lakehouse_audit/subject_audit.key`.
Set `KEY_PATH=...` or seed /tmp/lakehouse_audit/subject_audit.key (see systemd unit).
## Summary
**1 / 1** parity assertions passed.
**Status: PARITY** — every Rust assertion matches Go byte-for-byte.
@@ -1,6 +1,6 @@
# Validator parity probe — Rust :3100 vs Go :4110
**Date:** 2026-05-02T08:59:17Z
**Date:** 2026-05-05T08:23:08Z
**Rust gateway:** `http://127.0.0.1:3100` · **Go gateway:** `http://127.0.0.1:4110`
Identical `POST /v1/validate` request → both runtimes. Match
@@ -9,124 +9,11 @@ Identical `POST /v1/validate` request → both runtimes. Match
| Case | Rust status | Go status | Status match | Body match |
|---|---:|---:|:---:|:---:|
| playbook_happy | 200 | 200 | ✓ | ✓ |
| playbook_missing_fingerprint | 422 | 422 | ✓ | ✗ |
| playbook_wrong_prefix | 422 | 422 | ✓ | ✗ |
| playbook_empty_endorsed | 422 | 422 | ✓ | ✗ |
| playbook_overfull | 422 | 422 | ✓ | ✗ |
| fill_phantom | 422 | 422 | ✓ | ✗ |
| playbook_missing_fingerprint | 422 | 422 | ✓ | ✓ |
| playbook_wrong_prefix | 422 | 422 | ✓ | ✓ |
| playbook_empty_endorsed | 422 | 422 | ✓ | ✓ |
| playbook_overfull | 422 | 422 | ✓ | ✓ |
| fill_phantom | 422 | 422 | ✓ | ✓ |
**Tally:** 1 match · 5 diff (out of 6 cases)
**Tally:** 6 match · 0 diff (out of 6 cases)
## Divergences
<details><summary>DIFF — `playbook_missing_fingerprint`</summary>
**Rust** (HTTP 422):
```json
{
"Schema": {
"field": "fingerprint",
"reason": "missing — required for Phase 25 validity window"
}
}
```
**Go** (HTTP 422):
```json
{
"Field": "fingerprint",
"Kind": "schema",
"Reason": "missing — required for Phase 25 validity window"
}
```
</details>
<details><summary>DIFF — `playbook_wrong_prefix`</summary>
**Rust** (HTTP 422):
```json
{
"Schema": {
"field": "operation",
"reason": "expected `fill: ...` prefix, got \"sms_draft: hello\""
}
}
```
**Go** (HTTP 422):
```json
{
"Field": "operation",
"Kind": "schema",
"Reason": "expected `fill: ...` prefix, got \"sms_draft: hello\""
}
```
</details>
<details><summary>DIFF — `playbook_empty_endorsed`</summary>
**Rust** (HTTP 422):
```json
{
"Completeness": {
"reason": "endorsed_names must be non-empty"
}
}
```
**Go** (HTTP 422):
```json
{
"Field": "",
"Kind": "completeness",
"Reason": "endorsed_names must be non-empty"
}
```
</details>
<details><summary>DIFF — `playbook_overfull`</summary>
**Rust** (HTTP 422):
```json
{
"Completeness": {
"reason": "endorsed_names (3) exceeds target_count × 2 (2)"
}
}
```
**Go** (HTTP 422):
```json
{
"Field": "",
"Kind": "completeness",
"Reason": "endorsed_names (3) exceeds target_count × 2 (2)"
}
```
</details>
<details><summary>DIFF — `fill_phantom`</summary>
**Rust** (HTTP 422):
```json
{
"Consistency": {
"reason": "fills[0].candidate_id \"W-PHANTOM-NEVER-EXISTS\" does not exist in worker roster"
}
}
```
**Go** (HTTP 422):
```json
{
"Field": "",
"Kind": "consistency",
"Reason": "fills[0].candidate_id \"W-PHANTOM-NEVER-EXISTS\" does not exist in worker roster"
}
```
</details>
@@ -0,0 +1,151 @@
# Playbook-Lift Reality Test — Run real_006
**Generated:** 2026-05-05T09:50:08.929241389Z
**Judge:** `qwen2.5:latest` (Ollama, resolved from env JUDGE_MODEL=qwen2.5:latest)
**Corpora:** `workers,ethereal_workers`
**Workers limit:** 5000
**Queries:** `tests/reality/real_coord_queries_v3.txt` (50 executed)
**K per pass:** 10
**Paraphrase pass:** ENABLED
**Re-judge pass:** ENABLED
**Evidence:** `reports/reality-tests/playbook_lift_real_006.json`
---
## Headline
| Metric | Value |
|---|---:|
| Total queries run | 50 |
| Cold-pass discoveries (judge-best ≠ top-1) | 9 |
| Warm-pass lifts (recorded playbook → top-1) | 9 |
| No change (judge-best already top-1, no playbook needed) | 41 |
| Playbook boosts triggered (warm pass) | 10 |
| Mean Δ top-1 distance (warm − cold) | -0.05014307 |
| **Paraphrase pass — recorded answer at rank 0 (top-1)** | **6 / 9** |
| Paraphrase pass — recorded answer at any rank in top-K | 9 / 9 |
| **Quality lift** (warm top-1 rating > cold top-1 rating) | **11 / 50** |
| Quality neutral (warm top-1 rating = cold top-1 rating) | 36 / 50 |
| Quality regressed (warm top-1 rating < cold top-1 rating) | 3 / 50 |
**Verbatim lift rate:** 9 of 9 discoveries became top-1 after warm pass.
---
## Per-query results
| # | Query | Cold top-1 | Cold judge-best (rank/rating) | Recorded? | Warm top-1 | Judge-best warm rank | Lift |
|---|---|---|---|---|---|---|---|
| 1 | Need 1 Loader in Kansas City MO starting at 17:30 for Corner | w-4806 | 0/5 | — | w-4806 | 0 | no |
| 2 | Need 2 Assemblers in Cincinnati OH starting at 14:30 for Gre | w-4371 | 0/4 | — | w-4371 | 0 | no |
| 3 | Need 1 Forklift Operator in Lexington KY starting at 08:30 f | e-8263 | 1/5 | ✓ w-4636 | w-4636 | 0 | **YES** |
| 4 | Need 2 Assemblers in Flint MI starting at 08:30 for Centenni | e-7186 | 1/4 | ✓ e-9319 | e-9319 | 0 | **YES** |
| 5 | Need 2 Welders in Indianapolis IN starting at 10:00 for Nort | e-5834 | 0/4 | — | e-5834 | 0 | no |
| 6 | Need 2 Material Handlers in Cincinnati OH starting at 13:00 | e-4871 | 4/2 | — | e-4871 | 4 | no |
| 7 | Need 3 Pickers in Flint MI starting at 17:00 for Centennial | e-5571 | 0/2 | — | e-5571 | 0 | no |
| 8 | Need 3 Packers in Indianapolis IN starting at 09:00 for Heri | w-279 | 0/4 | — | w-279 | 0 | no |
| 9 | Need 3 Assemblers in Columbus OH starting at 17:30 for River | w-281 | 0/4 | — | w-281 | 0 | no |
| 10 | Need 5 Machine Operators in Cleveland OH starting at 14:30 f | e-8279 | 0/4 | — | e-8279 | 0 | no |
| 11 | Need 2 Assemblers in Grand Rapids MI starting at 13:00 for C | w-4502 | 0/3 | — | w-4502 | 0 | no |
| 12 | Need 2 Pickers in Akron OH starting at 10:30 for Summit Indu | e-5655 | 2/2 | — | e-5655 | 2 | no |
| 13 | Need 3 Quality Techs in Lexington KY starting at 12:30 for K | e-6369 | 0/4 | — | e-6369 | 0 | no |
| 14 | Need 4 Assemblers in Gary IN starting at 12:00 for Heritage | e-1315 | 0/2 | — | e-1315 | 0 | no |
| 15 | Need 3 Packers in Toledo OH starting at 16:00 for Cornerston | e-4887 | 1/2 | — | e-4887 | 1 | no |
| 16 | Need 4 Warehouse Associates in Fort Wayne IN starting at 13: | w-4434 | 0/4 | — | w-4434 | 0 | no |
| 17 | Need 4 Assemblers in Columbus OH starting at 13:00 for Midwa | w-281 | 0/4 | — | w-281 | 0 | no |
| 18 | Need 2 Shipping Clerks in Chicago IL starting at 17:00 for M | w-4504 | 1/4 | ✓ w-1522 | w-1522 | 0 | **YES** |
| 19 | Need 2 Machine Operators in Chicago IL starting at 11:00 for | e-1251 | 0/4 | — | e-1251 | 0 | no |
| 20 | Need 3 CNC Operators in Grand Rapids MI starting at 10:00 fo | w-792 | 0/3 | — | w-792 | 0 | no |
| 21 | Need 1 Warehouse Associate in Lexington KY starting at 09:30 | e-2331 | 0/4 | — | e-2331 | 0 | no |
| 22 | Need 2 Material Handlers in Gary IN starting at 11:00 for He | e-18 | 0/2 | — | e-18 | 0 | no |
| 23 | Need 5 Assemblers in Fort Wayne IN starting at 16:30 for Mid | e-6271 | 7/3 | — | e-6271 | 7 | no |
| 24 | Need 1 Loader in Cincinnati OH starting at 08:00 for Summit | e-8843 | 1/5 | ✓ w-4473 | w-4473 | 0 | **YES** |
| 25 | Need 3 Quality Techs in Columbus OH starting at 12:00 for Ri | w-281 | 0/2 | — | w-281 | 0 | no |
| 26 | Need 1 Machine Operator in Columbus OH starting at 09:30 for | w-4815 | 0/4 | — | w-4815 | 0 | no |
| 27 | Need 3 Machine Operators in Madison WI starting at 12:00 for | w-2027 | 0/4 | — | w-2027 | 0 | no |
| 28 | Need 2 Material Handlers in Kansas City MO starting at 11:30 | e-6774 | 0/3 | — | e-6774 | 0 | no |
| 29 | Need 3 Loaders in Flint MI starting at 16:00 for Parallel Ma | w-4875 | 0/2 | — | w-4875 | 0 | no |
| 30 | Need 2 Welders in Louisville KY starting at 13:00 for Horizo | w-2267 | 0/4 | — | w-2267 | 0 | no |
| 31 | Need 1 CNC Operator in Flint MI starting at 10:30 for Horizo | e-317 | 7/3 | — | e-317 | 7 | no |
| 32 | Need 1 Material Handler in Columbus OH starting at 15:30 for | e-8676 | 1/4 | ✓ w-2589 | w-2589 | 0 | **YES** |
| 33 | Need 2 Forklift Operators in Louisville KY starting at 14:30 | w-1830 | 0/4 | — | w-1830 | 0 | no |
| 34 | Need 2 Warehouse Associates in Chicago IL starting at 10:00 | w-4743 | 7/4 | ✓ e-9171 | e-9171 | 0 | **YES** |
| 35 | Need 2 Material Handlers in Gary IN starting at 15:00 for Pa | w-4236 | 1/2 | — | w-4236 | 1 | no |
| 36 | Need 1 Forklift Operator in Grand Rapids MI starting at 10:0 | w-3227 | 3/2 | — | w-3227 | 3 | no |
| 37 | Need 2 Pickers in Louisville KY starting at 12:30 for Corner | e-6489 | 2/4 | ✓ e-7622 | e-7622 | 0 | **YES** |
| 38 | Need 2 Loaders in Indianapolis IN starting at 17:30 for Midw | e-9877 | 0/4 | — | e-9877 | 0 | no |
| 39 | Need 1 Shipping Clerk in Indianapolis IN starting at 12:30 f | w-4635 | 0/5 | — | w-4635 | 0 | no |
| 40 | Need 2 Assemblers in Cincinnati OH starting at 08:00 for Key | w-4945 | 0/4 | — | w-4945 | 0 | no |
| 41 | Need 5 Quality Techs in Kansas City MO starting at 11:30 for | e-5633 | 0/4 | — | e-5633 | 0 | no |
| 42 | Need 2 Machine Operators in Gary IN starting at 10:00 for He | e-1089 | 0/2 | — | e-1089 | 0 | no |
| 43 | Need 1 Packer in Chicago IL starting at 09:30 for Midway Dis | e-7746 | 0/5 | — | w-279 | 1 | no |
| 44 | Need 2 Pickers in Lexington KY starting at 17:30 for Vanguar | e-3375 | 0/4 | — | e-3375 | 0 | no |
| 45 | Need 2 Maintenance Techs in Grand Rapids MI starting at 17:0 | e-6083 | 0/2 | — | e-6083 | 0 | no |
| 46 | Need 1 Material Handler in Detroit MI starting at 10:30 for | w-3286 | 0/5 | — | w-3286 | 0 | no |
| 47 | Need 1 Welder in Akron OH starting at 15:00 for Summit Indus | e-6149 | 0/2 | — | e-6149 | 0 | no |
| 48 | Need 1 Shipping Clerk in Cincinnati OH starting at 13:30 for | e-4218 | 3/5 | ✓ w-3488 | w-3488 | 0 | **YES** |
| 49 | Need 5 Packers in Indianapolis IN starting at 10:30 for Midw | e-2746 | 2/4 | ✓ w-279 | w-279 | 0 | **YES** |
| 50 | Need 1 Forklift Operator in Louisville KY starting at 10:30 | w-1830 | 0/4 | — | w-1830 | 0 | no |
---
## Paraphrase pass — does the playbook help similar-but-different queries?
For each query whose Pass 1 cold pass recorded a playbook entry, the
judge model rephrased the query, and the rephrased version was sent
through warm matrix.search. The recorded answer ID's rank in those
results tests whether cosine on the embedded paraphrase finds the
recorded query's vector.
| # | Original (≤40c) | Paraphrase (≤60c) | Recorded answer | Paraphrase top-1 | Recorded rank | Paraphrase lift |
|---|---|---|---|---|---|---|
| 3 | Need 1 Forklift Operator in Lexington KY | Vanguard Components requires a Forklift Operator in Lexingto | w-4636 | w-4636 | 0 | **YES** |
| 4 | Need 2 Assemblers in Flint MI starting a | Centennial Packaging requires 2 Assemblers to start at 08:30 | e-9319 | e-9319 | 0 | **YES** |
| 18 | Need 2 Shipping Clerks in Chicago IL sta | Looking for 2 Shipping Clerks in Chicago, IL to start at 5:0 | w-1522 | w-4504 | 1 | no |
| 24 | Need 1 Loader in Cincinnati OH starting | Summit Industrial requires 1 Loader position from 08:00 onwa | w-4473 | w-4473 | 0 | **YES** |
| 32 | Need 1 Material Handler in Columbus OH s | Looking for a Material Handler in Columbus, OH who can start | w-2589 | w-2589 | 0 | **YES** |
| 34 | Need 2 Warehouse Associates in Chicago I | Looking for 2 Warehouse Associates to work from 10:00 onward | e-9171 | e-9171 | 0 | **YES** |
| 37 | Need 2 Pickers in Louisville KY starting | Looking for 2 Pickers in Louisville, KY to start at 12:30 fo | e-7622 | e-6489 | 2 | no |
| 48 | Need 1 Shipping Clerk in Cincinnati OH s | Summit Industrial requires a Shipping Clerk in Cincinnati, O | w-3488 | w-3488 | 0 | **YES** |
| 49 | Need 5 Packers in Indianapolis IN starti | Looking for 5 packers in Indianapolis, IN to start at 10:30 | w-279 | e-2746 | 1 | no |
---
## Honesty caveats
1. **Judge IS the ground truth proxy.** Without human-labeled relevance, the LLM
judge's verdict is what defines "best." If `qwen2.5:latest` rates badly,
the lift number is meaningless. To validate the judge itself, sample 5–10
verdicts manually and check agreement.
2. **Score-1.0 boost = distance halved.** Playbook math is
`distance' = distance × (1 - 0.5 × score)`. Lift requires the judge-best
result's pre-boost distance to be ≤ 2× the cold top-1's distance, otherwise
even halving doesn't promote it. Tight clusters → little visible lift.
3. **Verbatim vs paraphrase.** The verbatim lift rate (above) is the cheap
case — same query, recorded playbook, expected boost. The paraphrase
pass (when enabled) is the actual learning property: similar-but-different
queries hitting a recorded playbook. Compare verbatim and paraphrase
lift rates — paraphrase should be lower (semantic-distance gates some
playbook hits) but non-zero is the meaningful signal.
4. **Multi-corpus skew.** Default corpora=`workers,ethereal_workers` — if all judge-best
results land in one corpus, the matrix layer's purpose isn't being tested.
Check per-corpus distribution in the JSON.
5. **Judge resolution.** This run used `qwen2.5:latest` from
env JUDGE_MODEL=qwen2.5:latest.
Bumping the judge for run #N+1 means editing one line in lakehouse.toml.
6. **Paraphrase generation also uses the judge.** The same model that rates
relevance also rephrases queries. A judge that's bad at rating staffing
queries is probably also bad at rephrasing them. Worth sanity-checking
a sample of `paraphrase_query` values in the JSON before trusting the
paraphrase lift number.
## Next moves
- If lift rate ≥ 50% of discoveries: matrix layer + playbook is doing real
work. Move to paraphrase queries + tag-based boost (currently ignored).
- If lift rate < 20%: investigate why — judge variance, distance gap too
wide, or playbook math too gentle. The score=1.0 / 0.5× formula may need
retuning.
- If discovery rate (cold judge-best ≠ top-1) is itself low: cosine is
already close to optimal on this query distribution. Either the corpus
is too narrow or the queries are too easy.
+234
View File
@@ -0,0 +1,234 @@
# Reality test real_006 — distribution-shift findings
**Run:** 2026-05-05 04:41:46 → 04:50:08 CDT (8m22s driver wall, ~14 min including ingest)
**Judge:** `qwen2.5:latest` (Ollama, local) — anchor's recommended judge, ~9s/query
**Queries:** 50 from `tests/reality/real_coord_queries_v3.txt` (rows 10-59 of fill_events.parquet, single `need` style)
**Corpora:** `workers,ethereal_workers` (5K + 10K)
**Local-only:** zero cloud calls per PRD line 70.
Companion to `playbook_lift_real_006.{json,md}`. That's the harness output; this is the reading.
---
## Why this test exists
real_001-005 all sourced their queries from the **first 10 rows** of
`fill_events.parquet`. `gen_real_queries.go` had `-limit N` but no
`-offset N`, so every "real" reality test ran on the same memorized
slice. The published "8 / 10 cold-pass top-1 = judge-best" was a
property of those 10 rows, not measured generalization. real_006
closes the methodology gap: new `-offset` flag samples rows 10-59 (5×
the count, never seen by the substrate).
---
## Headline — substrate generalizes (mostly)
| Metric | real_001 (10 queries, rows 0-9) | real_006 (50 queries, rows 10-59) | Verdict |
|---|---:|---:|---|
| Cold-pass top-1 = judge-best (rank match) | 8 / 10 (80%) | **41 / 50 (82%)** | **HOLDS** |
| Cold-pass top-1 = judge-best AND rating ≥ 2 | 8 / 10 (80%) | 34 / 50 (68%) | -12 pts |
| Mean cold top-1 judge rating | ~3.3 | 3.08 | -7% |
| Discoveries (judge promoted non-top-1) | 2 / 10 | 9 / 50 (18%) | comparable |
| Verbatim lift (discovery → warm top-1) | 2 / 2 (100%) | 9 / 9 (100%) | **HOLDS** |
| Paraphrase recovery → top-1 | n/a (disabled) | 6 / 9 (67%) | new |
| Quality regressed on rejudge | 0 (test absent) | 3 / 50 (6%) | new |
**Reading:** the substrate's *rank* behavior generalizes cleanly — the
top-1 worker is judge-approved at the same rate on fresh data as on
memorized data. The *quality* of top-1 (rating ≥ 2) drops 12 points,
which means 7 of the 41 "no-discovery" queries had cold top-1 the
judge rated 1 (irrelevant) but the corpus had nothing better. Honest
signal: parts of the v3 slice are in territory the workers corpus
doesn't cover well.
The verbatim-lift property (discovery → warm top-1) is **clean at
9/9**, matching real_001's 2/2 perfectly. When the playbook records,
the recorded answer comes back next time. That's the load-bearing
learning property.
---
## Cluster analysis — the cross-pollination question
real_001 found that same-(client, city) clusters cause Shape A boost
to bleed across roles. Real_002's role-gate fix (`roleEqual`) was
supposed to close that. real_006 has *more* cluster opportunities than
real_001 did:
| Cluster | Count | Result |
|---|---:|---|
| Riverfront Steel + Columbus OH | 4 | mostly clean — see below |
| Heritage Foods + Gary IN | 3 | **clean** — distinct workers per role, no boost firing |
| Cornerstone Fabrication + Louisville KY | 3 | clean |
| Midway Distribution + Chicago IL | 3 | **bleed: Q43 regressed** |
### Heritage Foods + Gary IN (3 queries, all clean)
```
Q14 Assemblers → e-1315
Q22 Material Handler → e-18
Q42 Machine Operator → e-1089
```
Three different roles → three different workers. Zero boosts fired,
zero playbooks recorded. **Role-disambiguation works at the cosine
level for this cluster.** Comparable to real_002's role-gate
demonstration.
### Riverfront Steel + Columbus OH (4 queries, partial)
```
Q9 Assemblers → w-281 (cold = warm, no boost)
Q25 Quality Techs → w-281 (cold = warm, no boost) ← same worker as Q9!
Q26 Machine Operator → w-4815 (clean)
Q32 Material Handler → e-8676 → w-2589 (judge promoted, playbook recorded)
```
Q9 and Q25 both surface `w-281` cold-pass for *different roles* —
that's a **cosine-level confusion** in the workers corpus, not a
playbook bleed. The substrate isn't breaking; the corpus contains a
worker whose resume embeds close to both "Assemblers" and "Quality
Techs" in this client+city. Judge-rating Q25 dropped 2 → 1 on
rejudge, which is the LLM's own consistency drift, not a substrate
fault. Worth noting but not a bug.
### Midway Distribution + Chicago IL (3 queries) — the regression
```
Q18 Shipping Clerks → cold w-4504 → warm w-1522 (boost=1, playbook recorded)
Q19 Machine Operators → cold = warm e-1251 (clean)
Q43 Packer → cold e-7746 (rating 5) → warm w-279 (rating 2) ← regressed
```
**Diagnosis (2026-05-05 follow-up): the leak source isn't Q18 — it's Q49.**
Three queries in real_006 touch `w-279`:
| # | role-extract | client | city | result | playbook? |
|---|---|---|---|---|---|
| Q8 Packers Indianapolis IN Heritage Foods | Packers | Heritage Foods | Indianapolis | w-279 (cold = judge-best) | no |
| **Q49** Packers **Indianapolis IN** Midway Distribution | Packers | **Midway Distribution** | Indianapolis | cold e-2746 → warm w-279 (judge-best) | **yes — recorded** |
| Q43 Packer **Chicago IL** Midway Distribution | Packer | **Midway Distribution** | Chicago | cold e-7746 (rating 5) → warm w-279 (rating 2) | no |
Q49 recorded `w-279` with role=`Packers`, client=`Midway Distribution`,
city=`Indianapolis`. When Q43 ran with role=`Packer`,
client=`Midway Distribution`, city=`Chicago`:
- `roleEqual("Packer", "Packers")` → both normalize to `"packer"` →
**gate passes (correctly, by design)**
- Q49's recorded query embedding is close enough to Q43's that the
playbook hit's distance falls inside `DefaultPlaybookMaxInjectDistance = 0.20`
(role + client + count + time-token dominate cosine; only the city
and the singular/plural noun differ)
- Inject fires; `w-279` (an Indianapolis worker) surfaces at Q43's
warm top-1 in **Chicago**
- Judge correctly rates this 2/5 — wrong city
**The role gate IS working as designed.** What's missing is a
**city gate** (or more generally, a metadata-equality gate on the
demand attributes that don't appear in the role field). Real_002's
fix anticipated cross-role bleed (Forklift → CNC); it didn't
anticipate cross-city bleed within the same client+role.
**Why prior tests missed this:** real_001-005 sourced from rows 0-9
of fill_events.parquet. Among those 10 rows there was no
Midway-Distribution × Packer × (different cities) pair. real_006
includes rows 10-59 which contain Q43 (Chicago) and Q49 (Indianapolis)
on the same client+role — a structurally new combination the
substrate hadn't been tested against.
The methodology gap closing on itself: the offset-flag fix that
surfaced real_006's headline number (-12 pts strict) also surfaced
a real cross-city leak the gate doesn't catch.
**The other regressions** (Q4 Centennial Packaging Flint, Q25
Riverfront Steel Quality Techs) are smaller (3→2 and 2→1) and look
like judge consistency drift on borderline candidates. Q43 is the
structural one.
### Concrete fix surface (next session)
A city gate alongside the role gate would close this:
1. **`PlaybookEntry`** gains `City string` (or generalize to
`Metadata map[string]string`). Recorded at `playbookRecord` time
from the same query the role extractor parses.
2. **`InjectPlaybookMisses` + `ApplyPlaybookBoost`** add a
`cityEqual(queryCity, hit.Entry.City)` check after the role
check. Same "permissive on empty" semantics as `roleEqual`.
3. **Bin extractor** adds a city-extract regex (e.g.
`\s+in\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)*)\s+[A-Z]{2}`) to capture
the city + state token from the standard query shape.
4. **Unit tests** mirror the existing role-gate tests, locking the
exact Q43/Q49 scenario as a regression gate (and add an integration-
level test that record(role=Packers, city=Indianapolis) followed by
search(role=Packer, city=Chicago) doesn't surface the recorded
answer).
Estimated scope: 1 new field, 2 new gate checks, 1 new regex, ~5
tests. Same shape as real_002's role-gate fix. Half a session.
Open question: same-metro normalization ("Detroit MI" ≈ "Dearborn MI"?)
would help with real-world dispatch where coordinators legitimately
route across nearby cities. Punt that to future work — strict equality
closes the structural bleed without over-engineering.
---
## What this confirms vs falsifies
**Confirmed:**
- Substrate generalizes at the rank level (82% cold-top-1 = judge-best)
- Verbatim lift works (9/9 discoveries → warm top-1)
- Role-disambiguation works at cosine level for clean role-distinct
query distributions (Heritage Foods cluster is the proof)
- Paraphrase recovery is real (6/9 → top-1, 9/9 any-rank)
**Falsified / weakened:**
- "8/10 cold-pass top-1 = judge-best" was 12 points optimistic on
the strict (rating ≥ 2) interpretation. Real number on broader
data is ~68%, not 80%. Headline rank-match number (82%) holds.
- Real_002's role-gate fix is **not structurally airtight**. Q43
shows the cluster-bleed pattern can still fire under conditions
the prior tests didn't reach. Open question: which path is
leaking — extractor failure, gate scope, or cosine drift?
---
## Next moves (informed by this evidence)
1. **Diagnose Q43 specifically**: re-run the role extractor on its
query text, check whether Q18's playbook entry has a role field
recorded, look at the warm-pass top-K to see whether `w-279`
reaches there via boost, inject, or cosine-only.
2. **Strengthen the corpus for the role-city combos that scored
low rating** (the 7 queries where cold top-1 was rating=1). The
workers corpus has gaps the v3 slice surfaced.
3. **Don't ship the "80% generalizes" framing as-is.** The number
real_006 measured (82% rank, 68% rating ≥ 2) is the honest one
to publish.
This is what reality tests are for. Numbers from the memorized slice
gave a clean story; numbers from the held-out slice show where it
needs work.
---
## Repro
```bash
cd /home/profit/golangLAKEHOUSE
PATH=/usr/local/go/bin:$PATH go build -o bin/gen_real_queries ./scripts/cutover/gen_real_queries.go
./bin/gen_real_queries -limit 50 -offset 10 -styles need > tests/reality/real_coord_queries_v3.txt
PATH=/usr/local/go/bin:$PATH \
RUN_ID=real_006 \
JUDGE_MODEL=qwen2.5:latest \
QUERIES_FILE=tests/reality/real_coord_queries_v3.txt \
WITH_PARAPHRASE=1 \
WITH_REJUDGE=1 \
bash scripts/playbook_lift.sh
```
Local-only. No cloud calls.
+14 -5
View File
@@ -29,6 +29,7 @@ import (
func main() {
src := flag.String("src", "/home/profit/lakehouse/data/datasets/fill_events.parquet", "fill_events parquet path")
limit := flag.Int("limit", 10, "number of source rows to read")
offset := flag.Int("offset", 0, "skip the first N rows (lets reality tests sample beyond the memorized real_001 slice)")
styles := flag.String("styles", "need", "comma-separated styles to emit per row (need|client_first|looking|shorthand|all)")
flag.Parse()
@@ -58,16 +59,24 @@ func main() {
at := tbl.Column(10).Data().Chunk(0)
deadline := tbl.Column(12).Data().Chunk(0)
n := int(tbl.NumRows())
if *limit < n {
n = *limit
totalRows := int(tbl.NumRows())
start := *offset
if start < 0 {
start = 0
}
if start > totalRows {
start = totalRows
}
end := start + *limit
if end > totalRows {
end = totalRows
}
stylesList := parseStyles(*styles)
fmt.Println("# Real-shape coordinator queries — generated from fill_events.parquet")
fmt.Println("# (real-shape demand data; queries built mechanically from event rows).")
fmt.Printf("# Source: %s (%d rows total, %d emitted, styles=%v)\n", *src, tbl.NumRows(), n, stylesList)
fmt.Printf("# Source: %s (%d rows total, rows [%d,%d) emitted, styles=%v)\n", *src, totalRows, start, end, stylesList)
fmt.Println("#")
fmt.Println("# Styles:")
fmt.Println("# need: 'Need N {role}{s} in {city} {state} starting at {at} for {client}'")
@@ -80,7 +89,7 @@ func main() {
fmt.Println("# substrate's bleed behavior when the role gate is silently disabled.")
fmt.Println()
for i := 0; i < n; i++ {
for i := start; i < end; i++ {
ev := event{
client: client.ValueStr(i),
city: city.ValueStr(i),
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# embed_parity — verify Rust /ai/embed (now direct Ollama post-sidecar
# drop) and Go /v1/embed (via embedd) produce equivalent 768-dim
# vectors for identical input.
#
# Why: today's sidecar drop (lakehouse commit ba928b1) changed Rust's
# embed transport. Go's embedd has always been direct. A drift would
# break retrieval semantics — same query, different vector → different
# top-K matches → different staffing recommendations. This probe is
# the regression gate.
#
# Match metric: cosine similarity > 0.99999. Byte-equal comparison
# isn't realistic — Go represents embeddings as []float32 and Rust
# as Vec<f64>, so JSON round-trip introduces small float drift. What
# matters for retrieval correctness is cosine direction, which both
# runtimes preserve when calling the same Ollama with the same model.
#
# Forces both endpoints to use `nomic-embed-text` so the v1-vs-v2-moe
# default difference doesn't pollute the comparison.
#
# Outputs: reports/cutover/gauntlet_2026-05-02/parity/embed_parity.md
set -uo pipefail
cd "$(dirname "$0")/../../.."
RUST_GW="${RUST_GW:-http://127.0.0.1:3100}"
GO_GW="${GO_GW:-http://127.0.0.1:4110}"
MODEL="${EMBED_MODEL:-nomic-embed-text}"
THRESHOLD="${EMBED_COSINE_THRESHOLD:-0.99999}"
OUT_DIR="reports/cutover/gauntlet_2026-05-02/parity"
mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/embed_parity.md"
# Fixtures cover the staffing-domain text shapes plus stress shapes
# (unicode, very short, very long).
FIXTURES=(
"forklift operator"
"Welder, Toledo OH, 2nd shift"
"OSHA-30 certified driver"
"dental hygienist with 3 years experience"
"CNC machine operator graveyard"
"Café résumé ⭐ 你好"
"x"
"$(printf 'long fixture: %.0s' {1..200})end"
)
probe() {
local gw="$1" path="$2" text="$3"
curl -sf -m 15 -X POST "$gw$path" \
-H 'Content-Type: application/json' \
-d "$(jq -nc --arg t "$text" --arg m "$MODEL" '{texts:[$t], model:$m}')" 2>/dev/null
}
# Cosine via inline python3 (bash arithmetic doesn't have sqrt).
cosine() {
local rust_json="$1" go_json="$2"
python3 - <<EOF
import json, math, sys
r = json.loads('''$rust_json''')
g = json.loads('''$go_json''')
# Rust /ai/embed returns {embeddings: [[...]]}, dimensions field
# Go /v1/embed returns {vectors: [[...]]}, dimension field
rv = r.get("embeddings", r.get("vectors", []))[0]
gv = g.get("vectors", g.get("embeddings", []))[0]
if len(rv) != len(gv):
print(f"DIM_MISMATCH:rust={len(rv)},go={len(gv)}")
sys.exit(0)
dot = sum(a*b for a,b in zip(rv,gv))
nr = math.sqrt(sum(a*a for a in rv))
ng = math.sqrt(sum(b*b for b in gv))
if nr == 0 or ng == 0:
print("ZERO_NORM")
sys.exit(0)
print(f"{dot/(nr*ng):.10f}")
EOF
}
TOTAL=0; MATCH=0; DIFF=0
DIFF_DETAIL=""
for text in "${FIXTURES[@]}"; do
TOTAL=$((TOTAL+1))
rust_json=$(probe "$RUST_GW" "/ai/embed" "$text")
go_json=$(probe "$GO_GW" "/v1/embed" "$text")
if [ -z "$rust_json" ] || [ -z "$go_json" ]; then
DIFF=$((DIFF+1))
label=$(echo "$text" | head -c 60)
DIFF_DETAIL="$DIFF_DETAIL"$'\n'"- \`$label\`: helper failed (rust_empty=$([[ -z "$rust_json" ]] && echo y || echo n) go_empty=$([[ -z "$go_json" ]] && echo y || echo n))"
continue
fi
cos=$(cosine "$rust_json" "$go_json")
label=$(echo "$text" | head -c 60 | tr '\n' ' ')
case "$cos" in
DIM_MISMATCH:*|ZERO_NORM)
DIFF=$((DIFF+1))
DIFF_DETAIL="$DIFF_DETAIL"$'\n'"- \`$label\` → $cos"
;;
*)
# Compare cos vs threshold via awk (bash can't compare floats).
if awk -v c="$cos" -v t="$THRESHOLD" 'BEGIN{exit !(c >= t)}'; then
MATCH=$((MATCH+1))
else
DIFF=$((DIFF+1))
DIFF_DETAIL="$DIFF_DETAIL"$'\n'"- \`$label\` → cos=$cos (below threshold $THRESHOLD)"
fi
;;
esac
done
{
echo "# /v1/embed cross-runtime parity probe"
echo
echo "**Date:** $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "**Rust:** \`$RUST_GW/ai/embed\` · **Go:** \`$GO_GW/v1/embed\`"
echo "**Model:** \`$MODEL\` (forced — overrides each side's default)"
echo "**Match metric:** cosine similarity ≥ \`$THRESHOLD\`"
echo
echo "Identical text → both endpoints. Cosine compares vector"
echo "DIRECTION (the operationally-meaningful property for HNSW"
echo "retrieval); byte-equal isn't expected because Go round-trips"
echo "through float32 internally while Rust stays at f64."
echo
echo "**Tally:** $MATCH match · $DIFF diff (out of $TOTAL fixtures)"
if [ -n "$DIFF_DETAIL" ]; then
echo
echo "## Divergences"
echo "$DIFF_DETAIL"
else
echo
echo "_Cosine ≥ $THRESHOLD on every fixture — embed parity holds_"
echo "_post-sidecar-drop. Rust and Go produce vectors that point in_"
echo "_the same direction in 768-dim space._"
fi
} > "$OUT"
echo "[parity] embed: $MATCH match / $DIFF diff (out of $TOTAL) → $OUT"
[ "$DIFF" -eq 0 ]
@@ -0,0 +1,36 @@
// extract_json_helper — Go-side counterpart to the Rust
// parity_extract_json binary. Reads stdin, runs
// validator.ExtractJSON, prints {matched, value} JSON to stdout.
//
// Used exclusively by
// `scripts/cutover/parity/extract_json_parity.sh` to verify
// cross-runtime equivalence of extract_json across Rust and Go.
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/validator"
)
func main() {
buf, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "read stdin: %v\n", err)
os.Exit(1)
}
v := validator.ExtractJSON(string(buf))
out := map[string]any{
"matched": v != nil,
"value": v,
}
body, err := json.Marshal(out)
if err != nil {
fmt.Fprintf(os.Stderr, "marshal: %v\n", err)
os.Exit(1)
}
fmt.Println(string(body))
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# extract_json_parity — feed identical model-output strings through
# both Rust extract_json AND Go ExtractJSON; diff outputs.
#
# Why: the iteration loop's correctness hinges on extract_json finding
# the same JSON object in the same model output regardless of runtime.
# A divergence here means a model output that one runtime accepts and
# the other rejects (or worse, both accept but parse differently).
#
# Approach:
# 1. Run cargo test -p gateway extract_json to assert the LIVE Rust
# function still passes its own unit tests (substrate gate)
# 2. For each fixture (input, label) tuple:
# Rust: ./target/release/parity_extract_json < fixture
# Go: ./bin/parity_extract_json_go < fixture
# Compare {matched, value} JSON outputs
# 3. Emit a markdown report with per-fixture matches/diffs
#
# Outputs: reports/cutover/gauntlet_2026-05-02/parity/extract_json_parity.md
#
# Env overrides:
# RUST_REPO=/home/profit/lakehouse
# RUST_BIN=$RUST_REPO/target/release/parity_extract_json
set -uo pipefail
cd "$(dirname "$0")/../../.."
RUST_REPO="${RUST_REPO:-/home/profit/lakehouse}"
RUST_BIN="${RUST_BIN:-$RUST_REPO/target/release/parity_extract_json}"
GO_BIN="${GO_BIN:-./bin/parity_extract_json_go}"
OUT_DIR="reports/cutover/gauntlet_2026-05-02/parity"
mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/extract_json_parity.md"
export PATH="$PATH:/usr/local/go/bin"
# ── Build / verify both sides ───────────────────────────────────────
if [ ! -x "$RUST_BIN" ]; then
echo "[extract-json-parity] building Rust helper..."
(cd "$RUST_REPO" && cargo build -p gateway --bin parity_extract_json --release 2>&1 | tail -3)
fi
if [ ! -x "$RUST_BIN" ]; then
echo "[extract-json-parity] SKIP: $RUST_BIN missing"
exit 0
fi
# Run live Rust unit tests (substrate gate) — ensures our helper
# matches the production extract_json behavior.
echo "[extract-json-parity] running cargo test extract_json (substrate gate)..."
(cd "$RUST_REPO" && cargo test -p gateway --release extract_json 2>&1 | tail -8) > /tmp/rust_extract_test.log
if ! grep -q "test result: ok" /tmp/rust_extract_test.log; then
echo "[extract-json-parity] live Rust tests FAILED — aborting probe"
cat /tmp/rust_extract_test.log
exit 1
fi
echo " ✓ live Rust extract_json tests PASS"
# Build Go-side helper from internal/validator.ExtractJSON.
go build -o "$GO_BIN" ./scripts/cutover/parity/extract_json_helper
# ── Fixture set ─────────────────────────────────────────────────────
# Inline as label||raw pairs. Curated to exercise every documented
# branch:
# - fenced ```json``` block
# - fenced unlabeled ``` block
# - bare-braces with stray prose
# - first-balanced-of-many
# - nested object
# - unicode in string values
# - escaped quotes
# - empty object
# - top-level array (both runtimes return first inner object)
# - no JSON at all
# - malformed JSON-shaped text (depth balanced but invalid syntax)
# - very-large input (~10KB of prose around a tiny object)
declare -a FIXTURES=(
"fenced_json_block||Here's my answer:
\`\`\`json
{\"fills\":[{\"candidate_id\":\"W-1\"}]}
\`\`\`
Done."
"fenced_unlabeled||result:
\`\`\`
{\"k\":\"v\"}
\`\`\`"
"bare_braces||Here you go: {\"fills\":[{\"candidate_id\":\"W-2\"}]}"
"first_of_many||{\"a\":1} then {\"b\":2}"
"nested||prefix {\"outer\":{\"inner\":[1,2,3]},\"x\":\"y\"} suffix"
"unicode||{\"name\":\"Café résumé\",\"emoji\":\"⭐\"}"
"escaped_quotes||{\"msg\":\"she said \\\"hello\\\"\"}"
"empty_object||{}"
"array_of_objects||[{\"a\":1},{\"b\":2}]"
"no_json||just prose, no json"
"depth_balanced_invalid||{not a key: still not}"
"trailing_garbage||{\"k\":\"v\"} and then 5} more } stuff"
)
TOTAL=0; MATCH=0; DIFF=0
DIFF_DETAIL=""
for entry in "${FIXTURES[@]}"; do
IFS='||' read -r label raw <<<"$entry"
TOTAL=$((TOTAL+1))
rust_out=$(printf '%s' "$raw" | "$RUST_BIN" 2>/dev/null || echo "RUST_ERROR")
go_out=$(printf '%s' "$raw" | "$GO_BIN" 2>/dev/null || echo "GO_ERROR")
# Normalize JSON serialization (key order) before comparing.
rust_norm=$(echo "$rust_out" | jq -cS . 2>/dev/null || echo "$rust_out")
go_norm=$(echo "$go_out" | jq -cS . 2>/dev/null || echo "$go_out")
if [ "$rust_norm" = "$go_norm" ]; then
MATCH=$((MATCH+1))
else
DIFF=$((DIFF+1))
raw_short=$(printf '%s' "$raw" | head -c 120 | tr '\n' ' ')
DIFF_DETAIL="$DIFF_DETAIL"$'\n\n'"### $label"$'\n''**Input (first 120 chars):** `'"$raw_short"'`'$'\n\n''**Rust:**'$'\n''```json'$'\n'"$rust_norm"$'\n''```'$'\n\n''**Go:**'$'\n''```json'$'\n'"$go_norm"$'\n''```'
fi
done
# ── Report ──────────────────────────────────────────────────────────
{
echo "# extract_json parity probe — Rust vs Go"
echo
echo "**Date:** $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "**Rust helper:** \`$RUST_BIN\` (links live \`gateway::v1::iterate::extract_json\`)"
echo "**Go helper:** \`$GO_BIN\` (links live \`internal/validator.ExtractJSON\`)"
echo
echo "Identical model-output strings → both runtimes' \`extract_json\`."
echo "Match = identical \`{matched, value}\` JSON output."
echo
echo "**Substrate gate:** \`cargo test -p gateway extract_json\` PASS before probe."
echo
echo "**Tally:** $MATCH match · $DIFF diff (out of $TOTAL fixtures)"
if [ -n "$DIFF_DETAIL" ]; then
echo
echo "## Divergences"
echo "$DIFF_DETAIL"
else
echo
echo "_No divergences — extract_json parity holds across all fixtures._"
fi
} > "$OUT"
echo "[parity] extract_json: $MATCH match / $DIFF diff (out of $TOTAL) → $OUT"
[ "$DIFF" -eq 0 ]
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env bash
# materializer_parity — run Bun + Go materializer against an identical
# synthetic root, diff the resulting data/evidence/ JSONL files.
#
# This validates the parity claim from the 2026-05-02 port: "on-wire
# JSON shape matches TS so Bun and Go runs are interchangeable." Any
# divergence here is a finding the architecture comparison should
# record (precedent: 2026-05-02 validator parity probe surfaced the
# Rust serde-tagged-enum vs Go flat-struct error envelope gap).
#
# Approach:
# 1. Set up a temp ROOT with a fixed source data/_kb/distilled_facts.jsonl
# + observer_escalations.jsonl (small, every transform field exercised)
# 2. Run Bun materializer:
# bun run /home/profit/lakehouse/scripts/distillation/build_evidence_index.ts
# against TS-side ROOT (TS expects a real lakehouse repo layout)
# 3. Run Go materializer:
# ./bin/materializer -root <ROOT>
# against the same ROOT (after wiping evidence/ between runs)
# 4. Diff the output JSONL files, normalized for non-deterministic
# fields (provenance.recorded_at, ordering).
#
# Outputs: reports/cutover/gauntlet_2026-05-02/parity/materializer_parity.md
#
# Exit 0 = byte-equal (modulo timestamps); exit non-zero = drift.
#
# Env overrides:
# RUST_REPO=/home/profit/lakehouse # Rust legacy repo
# GO_BIN=./bin/materializer # Go binary (built per-call)
set -uo pipefail
cd "$(dirname "$0")/../../.."
RUST_REPO="${RUST_REPO:-/home/profit/lakehouse}"
GO_BIN="${GO_BIN:-./bin/materializer}"
OUT_DIR="reports/cutover/gauntlet_2026-05-02/parity"
mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/materializer_parity.md"
# Build Go materializer fresh.
export PATH="$PATH:/usr/local/go/bin"
go build -o "$GO_BIN" ./cmd/materializer
# Locate Bun. Skip with an explicit message if it's missing (CI without bun).
if ! command -v bun >/dev/null 2>&1; then
echo "[materializer-parity] SKIP: bun not on PATH"
exit 0
fi
# Confirm Rust-side materializer is present.
TS_MAT="$RUST_REPO/scripts/distillation/build_evidence_index.ts"
if [ ! -f "$TS_MAT" ]; then
echo "[materializer-parity] SKIP: $TS_MAT not found"
exit 0
fi
ROOT="$(mktemp -d)"
trap 'rm -rf "$ROOT"' EXIT INT TERM
mkdir -p "$ROOT/data/_kb"
# Synthetic distilled_facts — exercises the simplest transform shape.
cat > "$ROOT/data/_kb/distilled_facts.jsonl" <<EOF
{"run_id":"r1","source_label":"lab-a","created_at":"2026-04-26T00:00:00Z","extractor":"qwen3.5:latest","text":"first fact"}
{"run_id":"r2","source_label":"lab-b","created_at":"2026-04-26T01:00:00Z","extractor":"qwen3.5:latest","text":"second fact"}
{"run_id":"r3","source_label":"lab-c","created_at":"2026-04-26T02:00:00Z","extractor":"qwen2.5:latest","text":"third — different extractor"}
EOF
# observer_escalations — exercises the prompt_tokens/completion_tokens
# fields we added to EvidenceRecord during the materializer port.
cat > "$ROOT/data/_kb/observer_escalations.jsonl" <<EOF
{"ts":"2026-04-26T12:00:00Z","sig_hash":"abc123","cluster_endpoint":"/v1/chat","prompt_tokens":150,"completion_tokens":75,"analysis":"escalation review notes"}
EOF
# Pin recorded_at across BOTH runs so provenance.recorded_at matches.
PINNED_TS="2026-05-02T00:00:00Z"
# ── Bun run ────────────────────────────────────────────────────────
# Bun side reads its own root. We have to copy the fixtures under the
# Bun side's expectation (it defaults to /home/profit/lakehouse), or
# override via LH_DISTILL_ROOT. But the Bun materializer's CLI reads
# `new Date().toISOString()` for recorded_at — we can't pin without
# editing the script. Workaround: capture the full output, then
# normalize provenance.recorded_at out of both sides for diff.
BUN_ROOT="$ROOT/bun_side"
mkdir -p "$BUN_ROOT/data/_kb"
cp "$ROOT/data/_kb/"* "$BUN_ROOT/data/_kb/"
echo "[materializer-parity] running Bun materializer..."
LH_DISTILL_ROOT="$BUN_ROOT" bun run "$TS_MAT" \
> /tmp/bun_mat.log 2>&1 || {
echo "[materializer-parity] bun run failed:"
tail -30 /tmp/bun_mat.log
exit 1
}
# ── Go run ─────────────────────────────────────────────────────────
GO_ROOT="$ROOT/go_side"
mkdir -p "$GO_ROOT/data/_kb"
cp "$ROOT/data/_kb/"* "$GO_ROOT/data/_kb/"
echo "[materializer-parity] running Go materializer..."
"$GO_BIN" -root "$GO_ROOT" > /tmp/go_mat.log 2>&1 || {
echo "[materializer-parity] go run failed:"
tail -30 /tmp/go_mat.log
exit 1
}
# ── Find output day-partition ──────────────────────────────────────
# Both runs use today's UTC date. Look up the partition.
TODAY="$(date -u +%Y/%m/%d)"
BUN_OUT="$BUN_ROOT/data/evidence/$TODAY"
GO_OUT="$GO_ROOT/data/evidence/$TODAY"
if [ ! -d "$BUN_OUT" ]; then
echo "[materializer-parity] no Bun output dir: $BUN_OUT"
ls -la "$BUN_ROOT/data/evidence" 2>/dev/null || true
exit 1
fi
if [ ! -d "$GO_OUT" ]; then
echo "[materializer-parity] no Go output dir: $GO_OUT"
exit 1
fi
# ── Normalize + diff per source-stem ───────────────────────────────
# Stripped fields:
# provenance.recorded_at — different per-run wall clock
#
# Sorted by sig_hash so dedup ordering can't matter.
normalize() {
jq -c -S 'del(.provenance.recorded_at)' "$1" 2>/dev/null \
| sort
}
TOTAL=0; MATCH=0; DIFF=0
DIFF_DETAIL=""
for f in "$BUN_OUT"/*.jsonl; do
stem=$(basename "$f" .jsonl)
go_f="$GO_OUT/$stem.jsonl"
TOTAL=$((TOTAL+1))
if [ ! -f "$go_f" ]; then
DIFF=$((DIFF+1))
DIFF_DETAIL="$DIFF_DETAIL"$'\n'"- $stem: present in Bun, missing in Go"
continue
fi
bun_norm=$(normalize "$f")
go_norm=$(normalize "$go_f")
if [ "$bun_norm" = "$go_norm" ]; then
MATCH=$((MATCH+1))
else
DIFF=$((DIFF+1))
# Capture a small diff for the report.
diff_block="$(diff <(echo "$bun_norm") <(echo "$go_norm") | head -40)"
DIFF_DETAIL="$DIFF_DETAIL"$'\n\n'"### $stem"$'\n''```diff'$'\n'"$diff_block"$'\n''```'
fi
done
# ── Write report ───────────────────────────────────────────────────
{
echo "# Materializer parity probe — Bun vs Go"
echo
echo "**Date:** $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "**Bun:** \`$TS_MAT\`"
echo "**Go:** \`$GO_BIN\`"
echo
echo "Identical \`data/_kb/\` source → both runtimes' materializer."
echo "Match = JSONL byte-equal after normalizing \`provenance.recorded_at\`"
echo "(per-run wall clock) + sorted line order (dedup ordering)."
echo
echo "**Tally:** $MATCH match · $DIFF diff (out of $TOTAL stems)"
if [ -n "$DIFF_DETAIL" ]; then
echo
echo "## Divergences"
echo "$DIFF_DETAIL"
else
echo
echo "_No divergences — on-wire JSON parity holds._"
fi
} > "$OUT"
echo "[parity] materializer: $MATCH match / $DIFF diff (out of $TOTAL) → $OUT"
[ "$DIFF" -eq 0 ]
@@ -0,0 +1,69 @@
// session_log_helper — Go-side counterpart for the cross-runtime
// session-log parity probe. Reads a fixture JSON on stdin, builds a
// SessionRecord, emits one JSONL row to stdout.
//
// Used by scripts/cutover/parity/session_log_parity.sh.
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"time"
"git.agentview.dev/profit/golangLAKEHOUSE/internal/validator"
)
// fixtureInput shape mirrors the Rust helper's accepted shape so both
// helpers can read the same stdin payload.
type fixtureInput struct {
SessionID string `json:"session_id"`
Kind string `json:"kind"`
Model string `json:"model"`
Provider string `json:"provider"`
Prompt string `json:"prompt"`
Iterations int `json:"iterations"`
MaxIterations int `json:"max_iterations"`
FinalVerdict string `json:"final_verdict"`
Attempts []validator.SessionAttemptRecord `json:"attempts"`
Artifact map[string]any `json:"artifact"`
GroundedInRoster *bool `json:"grounded_in_roster"`
DurationMs int64 `json:"duration_ms"`
}
func main() {
buf, err := io.ReadAll(os.Stdin)
if err != nil {
fmt.Fprintf(os.Stderr, "read stdin: %v\n", err)
os.Exit(1)
}
var in fixtureInput
if err := json.Unmarshal(buf, &in); err != nil {
fmt.Fprintf(os.Stderr, "parse stdin: %v\n", err)
os.Exit(1)
}
rec := validator.SessionRecord{
Schema: validator.SessionRecordSchema,
SessionID: in.SessionID,
Timestamp: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).Format(time.RFC3339),
Daemon: "validatord",
Kind: in.Kind,
Model: in.Model,
Provider: in.Provider,
Prompt: in.Prompt,
Iterations: in.Iterations,
MaxIterations: in.MaxIterations,
FinalVerdict: in.FinalVerdict,
Attempts: in.Attempts,
Artifact: in.Artifact,
GroundedInRoster: in.GroundedInRoster,
DurationMs: in.DurationMs,
}
body, err := json.Marshal(rec)
if err != nil {
fmt.Fprintf(os.Stderr, "marshal: %v\n", err)
os.Exit(1)
}
fmt.Println(string(body))
}
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# session_log_parity — verify Rust gateway and Go validatord write
# byte-equivalent SessionRecord JSONL rows from identical input.
#
# Why: the longitudinal session log (one row per /v1/iterate session)
# is the offline-analysis layer. Cross-runtime drift means a DuckDB
# query that works against one log fails against the other — the
# whole "unified longitudinal view" theory unravels. This probe is
# the regression gate.
#
# Approach (pure schema check, no live daemon needed):
# 1. Build both runtimes' session_log writers as standalone helper
# binaries that take a fixture JSON on stdin and emit one row.
# 2. Feed identical fixture inputs through each.
# 3. Diff the resulting rows after normalizing the few fields that
# MUST differ (timestamp, daemon).
#
# Outputs: reports/cutover/gauntlet_2026-05-02/parity/session_log_parity.md
#
# Exit 0 on schema match; exit 1 on drift.
set -uo pipefail
cd "$(dirname "$0")/../../.."
RUST_REPO="${RUST_REPO:-/home/profit/lakehouse}"
RUST_BIN="${RUST_BIN:-$RUST_REPO/target/release/parity_session_log}"
GO_BIN="${GO_BIN:-./bin/parity_session_log_go}"
OUT_DIR="reports/cutover/gauntlet_2026-05-02/parity"
mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/session_log_parity.md"
export PATH="$PATH:/usr/local/go/bin"
# ── Build both helpers ─────────────────────────────────────────────
go build -o "$GO_BIN" ./scripts/cutover/parity/session_log_helper
if [ ! -x "$RUST_BIN" ]; then
echo "[session-log-parity] building Rust helper..."
(cd "$RUST_REPO" && cargo build -p gateway --bin parity_session_log --release 2>&1 | tail -3)
fi
if [ ! -x "$RUST_BIN" ]; then
echo "[session-log-parity] SKIP: $RUST_BIN missing"
exit 0
fi
# ── Fixtures ───────────────────────────────────────────────────────
# Parallel arrays — labels and fixtures keyed by index. Avoids a
# separator-in-string scheme (the prior `||` form silently injected
# a `|` character into every fixture body, which both helpers then
# rejected with empty output → trivially-equal "match" — caught
# during initial probe authoring 2026-05-02).
LABELS=(
"accepted_grounded"
"max_iter_exhausted"
"infra_error"
"unicode_in_prompt"
)
FIXTURES=(
'{"session_id":"trace-1","kind":"fill","model":"qwen3.5:latest","provider":"ollama","prompt":"produce a fill","iterations":1,"max_iterations":3,"final_verdict":"accepted","attempts":[{"iteration":0,"verdict_kind":"accepted","span_id":"sp-0"}],"artifact":{"fills":[{"candidate_id":"W-1"}]},"grounded_in_roster":true,"duration_ms":50}'
'{"session_id":"trace-2","kind":"fill","model":"qwen3.5:latest","provider":"ollama","prompt":"P","iterations":3,"max_iterations":3,"final_verdict":"max_iter_exhausted","attempts":[{"iteration":0,"verdict_kind":"validation_failed","error":"phantom W-X","span_id":"sp-a"},{"iteration":1,"verdict_kind":"validation_failed","error":"phantom W-Y","span_id":"sp-b"},{"iteration":2,"verdict_kind":"no_json","span_id":"sp-c"}],"duration_ms":3200}'
'{"session_id":"trace-3","kind":"playbook","model":"qwen","provider":"ollama","prompt":"P","iterations":0,"max_iterations":3,"final_verdict":"infra_error","attempts":[{"iteration":0,"verdict_kind":"infra_error","error":"connection refused"}],"duration_ms":12}'
'{"session_id":"trace-4","kind":"playbook","model":"qwen","provider":"ollama","prompt":"Café résumé ⭐ 你好","iterations":1,"max_iterations":3,"final_verdict":"accepted","attempts":[{"iteration":0,"verdict_kind":"accepted","span_id":"sp-u"}],"artifact":{"endorsed_names":["W-1"],"operation":"fill: X x1 in A, B","fingerprint":"abc"},"duration_ms":80}'
)
normalize() {
# Strip the few fields that MUST differ between Rust+Go runs
# (timestamp = now; daemon = producer name). Sort the keys so the
# JSON serialization order doesn't break a byte-equal diff.
jq -cS 'del(.timestamp) | del(.daemon)' <<<"$1"
}
TOTAL=0; MATCH=0; DIFF=0
DIFF_DETAIL=""
for i in "${!FIXTURES[@]}"; do
label="${LABELS[$i]}"
fixture="${FIXTURES[$i]}"
TOTAL=$((TOTAL+1))
rust_row=$(printf '%s' "$fixture" | "$RUST_BIN" 2>&1 || echo '{"error":"rust_helper_failed"}')
go_row=$(printf '%s' "$fixture" | "$GO_BIN" 2>&1 || echo '{"error":"go_helper_failed"}')
# Refuse trivial-equal-empty matches: if either side produced no
# parseable JSON at all, mark this as a diff rather than a match.
if ! echo "$rust_row" | jq -e . >/dev/null 2>&1; then
rust_norm="<rust helper failed: $(echo "$rust_row" | head -c 200)>"
else
rust_norm=$(normalize "$rust_row")
fi
if ! echo "$go_row" | jq -e . >/dev/null 2>&1; then
go_norm="<go helper failed: $(echo "$go_row" | head -c 200)>"
else
go_norm=$(normalize "$go_row")
fi
if [ "$rust_norm" = "$go_norm" ]; then
MATCH=$((MATCH+1))
else
DIFF=$((DIFF+1))
DIFF_DETAIL="$DIFF_DETAIL"$'\n\n'"### $label"$'\n\n''**Rust:**'$'\n''```json'$'\n'"$rust_norm"$'\n''```'$'\n\n''**Go:**'$'\n''```json'$'\n'"$go_norm"$'\n''```'
fi
done
# ── Report ─────────────────────────────────────────────────────────
{
echo "# session_log parity probe — Rust gateway vs Go validatord"
echo
echo "**Date:** $(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "**Rust helper:** \`$RUST_BIN\`"
echo "**Go helper:** \`$GO_BIN\`"
echo
echo "Identical fixture inputs through each runtime's"
echo "\`SessionRecord\` builder + JSON marshaler. Match = byte-equal"
echo "after stripping \`timestamp\` (per-run wall clock) + \`daemon\`"
echo "(\"gateway\" on Rust side, \"validatord\" on Go side; both are"
echo "valid producers in the same longitudinal log)."
echo
echo "**Tally:** $MATCH match · $DIFF diff (out of $TOTAL fixtures)"
if [ -n "$DIFF_DETAIL" ]; then
echo
echo "## Divergences"
echo "$DIFF_DETAIL"
else
echo
echo "_No divergences — schema parity holds across all fixtures._"
fi
} > "$OUT"
echo "[parity] session_log: $MATCH match / $DIFF diff (out of $TOTAL) → $OUT"
[ "$DIFF" -eq 0 ]
@@ -0,0 +1,168 @@
// Cross-runtime parity helper — Go side.
//
// Specification: /home/profit/lakehouse/docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §5 Step 8.
//
// Counterpart of crates/catalogd/src/bin/parity_subject_audit.rs.
// Both helpers MUST produce byte-identical output for the same input.
//
// Modes:
//
// --known-answer
// Print canonical-JSON + HMAC for a hardcoded fixture. Compared
// byte-for-byte against the Rust helper's output. If they
// differ, the canonical-JSON or HMAC algorithm has drifted.
//
// --verify <audit_log_path> --key <key_path>
// Replay the HMAC chain on a real audit JSONL. Print one JSON
// object: {mode, count, tip, verified, error}.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"time"
cat "git.agentview.dev/profit/golangLAKEHOUSE/internal/catalogd"
)
const genesis = "GENESIS"
func deterministicKey() []byte {
k := make([]byte, 32)
for i := range k {
k[i] = byte(i)
}
return k
}
// knownAnswerOut is intentionally identical to KnownAnswerOut in the
// Rust helper so a stdout diff is a one-line semantic comparison.
type knownAnswerOut struct {
Mode string `json:"mode"`
Canonical string `json:"canonical"`
Hmac string `json:"hmac"`
CanonicalBytesLen int `json:"canonical_bytes_len"`
}
type verifyOut struct {
Mode string `json:"mode"`
Count int `json:"count"`
Tip string `json:"tip"`
Verified bool `json:"verified"`
Error *string `json:"error"`
}
func runKnownAnswer() {
row := cat.SubjectAuditRow{
Schema: "subject_audit.v1",
Ts: time.Date(2026, 5, 3, 12, 0, 0, 0, time.UTC),
CandidateID: "WORKER-FIXED",
Accessor: cat.AuditAccessor{
Kind: "gateway_lookup",
Daemon: "gateway",
Purpose: "parity_test",
TraceID: "trace-fixed",
},
FieldsAccessed: []string{"name"},
Result: "success",
PrevChainHash: genesis,
}
canonical, hmacHex, err := cat.CanonicalAndHmac(&row, deterministicKey(), genesis)
if err != nil {
die("canonical/hmac: %v", err)
}
out := knownAnswerOut{
Mode: "known_answer",
Canonical: string(canonical),
Hmac: hmacHex,
CanonicalBytesLen: len(canonical),
}
emit(out)
}
func runVerify(auditPath, keyPath string) {
entries, err := cat.ReadAuditLog(auditPath)
if err != nil {
die("read audit log: %v", err)
}
key, err := os.ReadFile(keyPath)
if err != nil {
die("read key: %v", err)
}
count, tip, verr := cat.VerifyChain(entries, key)
out := verifyOut{
Mode: "verify",
Count: count,
Tip: tip,
Verified: verr == nil,
}
if verr != nil {
s := verr.Error()
out.Error = &s
// Reset count + tip to match the Rust helper's error semantics.
out.Count = 0
out.Tip = genesis
}
emit(out)
}
func emit(v any) {
bs, err := json.Marshal(v)
if err != nil {
die("marshal output: %v", err)
}
fmt.Println(string(bs))
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(2)
}
func main() {
args := os.Args[1:]
var (
knownAnswer bool
auditPath string
keyPath string
)
for i := 0; i < len(args); i++ {
switch args[i] {
case "--known-answer":
knownAnswer = true
case "--verify":
if i+1 >= len(args) {
die("--verify needs a path")
}
auditPath = args[i+1]
i++
case "--key":
if i+1 >= len(args) {
die("--key needs a path")
}
keyPath = args[i+1]
i++
case "-h", "--help":
fmt.Fprintln(os.Stderr, "subject_audit_helper --known-answer")
fmt.Fprintln(os.Stderr, "subject_audit_helper --verify <audit_log> --key <key_file>")
os.Exit(0)
default:
die("unknown arg: %s", args[i])
}
}
if knownAnswer {
runKnownAnswer()
return
}
if auditPath == "" || keyPath == "" {
die("need --known-answer OR (--verify <path> --key <path>)")
}
// Sanity: file naming convention <candidate_id>.audit.jsonl.
if !strings.HasSuffix(filepath.Base(auditPath), ".audit.jsonl") {
die("audit log path must end with .audit.jsonl")
}
runVerify(auditPath, keyPath)
}
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
# subject_audit_parity — verify Rust and Go produce byte-identical
# canonical JSON + HMAC-SHA256 chain hashes for subject audit logs.
#
# Why: the SubjectManifest + audit-log substrate (Rust crates/catalogd
# subject_audit.rs) is consumed by the legal-tier endpoint /audit/subject/{id}
# AND by the Go internal/catalogd reader. A canonical-JSON drift between
# the two runtimes would mean a chain written by Rust does not verify
# under Go (or worse, verifies as tampered) — silently breaking the
# defensible-audit guarantee.
#
# The probe runs in two phases:
#
# Phase 1 — Known-answer vector
# Both helpers serialize the same hardcoded SubjectAuditRow,
# emit canonical bytes + HMAC. Outputs MUST be byte-identical.
# This catches algorithm drift independent of any real data.
#
# Phase 2 — Real production audit logs
# Walk every *.audit.jsonl in the live data dir. Run BOTH helpers
# against each one with the production signing key. Outputs MUST
# be byte-identical. This catches real-world drift (e.g. struct
# field tag mismatches that only fire on certain field values).
#
# Outputs: reports/cutover/gauntlet_2026-05-02/parity/subject_audit_parity.md
#
# Env overrides:
# RUST_REPO=/home/profit/lakehouse
# RUST_BIN=$RUST_REPO/target/release/parity_subject_audit
# GO_BIN=./bin/subject_audit_helper
# AUDIT_DIR=$RUST_REPO/data/_catalog/subjects
# KEY_PATH=/tmp/lakehouse_audit/subject_audit.key
set -uo pipefail
cd "$(dirname "$0")/../../.."
RUST_REPO="${RUST_REPO:-/home/profit/lakehouse}"
RUST_BIN="${RUST_BIN:-$RUST_REPO/target/release/parity_subject_audit}"
GO_BIN="${GO_BIN:-./bin/subject_audit_helper}"
AUDIT_DIR="${AUDIT_DIR:-$RUST_REPO/data/_catalog/subjects}"
KEY_PATH="${KEY_PATH:-/tmp/lakehouse_audit/subject_audit.key}"
OUT_DIR="reports/cutover/gauntlet_2026-05-02/parity"
mkdir -p "$OUT_DIR"
OUT="$OUT_DIR/subject_audit_parity.md"
export PATH="$PATH:/usr/local/go/bin"
# ── Build / verify both sides ───────────────────────────────────────
if [ ! -x "$RUST_BIN" ]; then
echo "[subject-audit-parity] building Rust helper..."
(cd "$RUST_REPO" && cargo build -p catalogd --bin parity_subject_audit --release 2>&1 | tail -3)
fi
if [ ! -x "$RUST_BIN" ]; then
echo "[subject-audit-parity] SKIP: $RUST_BIN missing"
exit 0
fi
if [ ! -x "$GO_BIN" ]; then
echo "[subject-audit-parity] building Go helper..."
go build -o "$GO_BIN" ./scripts/cutover/parity/subject_audit_helper/
fi
if [ ! -x "$GO_BIN" ]; then
echo "[subject-audit-parity] FAIL: $GO_BIN missing after build"
exit 1
fi
# ── Report header ───────────────────────────────────────────────────
{
echo "# subject_audit_parity"
echo
echo "**Generated:** $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
echo "**Spec:** /home/profit/lakehouse/docs/specs/SUBJECT_MANIFESTS_ON_CATALOGD.md §5 Step 8"
echo "**Rust helper:** \`$RUST_BIN\`"
echo "**Go helper:** \`$GO_BIN\`"
echo "**Audit dir:** \`$AUDIT_DIR\`"
echo
} > "$OUT"
PASS=0
FAIL=0
# ── Phase 1 — Known-answer vector ───────────────────────────────────
{
echo "## Phase 1 — Known-answer vector"
echo
echo "Hardcoded fixture row, identical inputs, byte-compare canonical-JSON + HMAC."
echo
} >> "$OUT"
RUST_KA="$(mktemp)"; GO_KA="$(mktemp)"
"$RUST_BIN" --known-answer > "$RUST_KA" 2>&1 || true
"$GO_BIN" --known-answer > "$GO_KA" 2>&1 || true
if diff -q "$RUST_KA" "$GO_KA" >/dev/null 2>&1; then
PASS=$((PASS+1))
{
echo "**MATCH** ✓"
echo
echo '```json'
cat "$RUST_KA"
echo '```'
echo
} >> "$OUT"
else
FAIL=$((FAIL+1))
{
echo "**MISMATCH** ✗"
echo
echo "### Rust"
echo '```json'
cat "$RUST_KA"
echo '```'
echo
echo "### Go"
echo '```json'
cat "$GO_KA"
echo '```'
echo
echo "### Diff"
echo '```diff'
diff "$RUST_KA" "$GO_KA" || true
echo '```'
echo
} >> "$OUT"
fi
# ── Phase 2 — Real production audit logs ────────────────────────────
{
echo "## Phase 2 — Real production audit logs"
echo
echo "Every \`*.audit.jsonl\` in \`$AUDIT_DIR\` verified by both runtimes."
echo
} >> "$OUT"
if [ ! -r "$KEY_PATH" ]; then
{
echo "**SKIP** — signing key not readable at \`$KEY_PATH\`."
echo "Set \`KEY_PATH=...\` or seed /tmp/lakehouse_audit/subject_audit.key (see systemd unit)."
echo
} >> "$OUT"
else
shopt -s nullglob
LOGS=( "$AUDIT_DIR"/*.audit.jsonl )
if [ "${#LOGS[@]}" -eq 0 ]; then
{
echo "**SKIP** — no \`*.audit.jsonl\` files under \`$AUDIT_DIR\`."
echo "(Trigger one by hitting the gateway with /v1/validate on a candidate_id.)"
echo
} >> "$OUT"
else
{
echo "| Audit log | Rust verified | Go verified | Result |"
echo "|---|---|---|---|"
} >> "$OUT"
for log in "${LOGS[@]}"; do
label="$(basename "$log")"
RUST_OUT="$(mktemp)"; GO_OUT="$(mktemp)"
"$RUST_BIN" --verify "$log" --key "$KEY_PATH" > "$RUST_OUT" 2>&1 || true
"$GO_BIN" --verify "$log" --key "$KEY_PATH" > "$GO_OUT" 2>&1 || true
rust_count=$(jq -r '.count // 0' < "$RUST_OUT" 2>/dev/null || echo "?")
go_count=$(jq -r '.count // 0' < "$GO_OUT" 2>/dev/null || echo "?")
rust_ok=$(jq -r '.verified // false' < "$RUST_OUT" 2>/dev/null || echo "?")
go_ok=$(jq -r '.verified // false' < "$GO_OUT" 2>/dev/null || echo "?")
if diff -q "$RUST_OUT" "$GO_OUT" >/dev/null 2>&1; then
PASS=$((PASS+1))
echo "| \`$label\` | $rust_count rows ($rust_ok) | $go_count rows ($go_ok) | **MATCH** ✓ |" >> "$OUT"
else
FAIL=$((FAIL+1))
{
echo "| \`$label\` | $rust_count rows ($rust_ok) | $go_count rows ($go_ok) | **MISMATCH** ✗ |"
echo
echo "### Diff for \`$label\`"
echo '```diff'
diff "$RUST_OUT" "$GO_OUT" || true
echo '```'
echo
} >> "$OUT"
fi
rm -f "$RUST_OUT" "$GO_OUT"
done
fi
fi
rm -f "$RUST_KA" "$GO_KA"
# ── Summary ─────────────────────────────────────────────────────────
TOTAL=$((PASS + FAIL))
{
echo
echo "## Summary"
echo
echo "**$PASS / $TOTAL** parity assertions passed."
echo
if [ "$FAIL" -gt 0 ]; then
echo "**Status: DIVERGED** — Rust and Go disagree on at least one canonical-JSON or HMAC computation."
echo "Investigate the diff above before declaring cross-runtime parity."
else
echo "**Status: PARITY** — every Rust assertion matches Go byte-for-byte."
fi
} >> "$OUT"
echo "[subject-audit-parity] $PASS / $TOTAL pass — report: $OUT"
[ "$FAIL" -eq 0 ]
+30 -15
View File
@@ -135,32 +135,47 @@ start observerd 4219
start matrixd 4218
start gateway 4110
# chatd is started independently — its provider key files come from
# /etc/lakehouse/{ollama_cloud,openrouter,opencode,kimi}.env; if
# chatd is already up (long-running from a prior session) we don't
# touch it. chatd uses no S3, so no temp-toml override needed.
if ! curl -sSf -m 1 http://127.0.0.1:3220/health >/dev/null 2>&1; then
echo "[gostack] chatd :3220 not up; starting"
nohup ./bin/chatd -config lakehouse.toml > /tmp/gostack-logs/chatd.log 2>&1 & disown
# chatd + validatord are started independently — both sit OUTSIDE
# the persistent :4xxx range and are SHARED with smoke harnesses
# (no bucket / port conflict because chatd has no S3 state and
# validatord's state is JSONL files, not MinIO). Provider key files
# come from /etc/lakehouse/{ollama_cloud,openrouter,opencode,kimi}.env.
# If either is already up (long-running from a prior session) we
# don't touch it. The matching smokes (chatd_smoke / validatord_smoke)
# do `pkill -f "bin/<name>$"` on teardown — re-running this script
# brings them back. No temp-toml override needed for either.
start_shared() {
local bin="$1"
local port="$2"
if curl -sSf -m 1 "http://127.0.0.1:$port/health" >/dev/null 2>&1; then
echo " $bin :$port already up (skipping)"
return 0
fi
echo "[gostack] $bin :$port not up; starting"
nohup ./bin/"$bin" -config lakehouse.toml > "/tmp/gostack-logs/$bin.log" 2>&1 & disown
for _ in $(seq 1 50); do
if curl -sSf -m 1 "http://127.0.0.1:3220/health" >/dev/null 2>&1; then
echo " chatd :3220 up"
break
if curl -sSf -m 1 "http://127.0.0.1:$port/health" >/dev/null 2>&1; then
echo " $bin :$port up"
return 0
fi
sleep 0.1
done
else
echo " chatd :3220 already up (skipping)"
fi
echo " $bin :$port FAILED — log tail:"
tail -20 "/tmp/gostack-logs/$bin.log"
return 1
}
start_shared chatd 3220
start_shared validatord 3221
echo
echo "[gostack] ready · sweep:"
for p in 4110 4211 4212 4213 4214 4215 4216 4217 4218 4219 3220; do
for p in 4110 4211 4212 4213 4214 4215 4216 4217 4218 4219 3220 3221; do
curl -sSf -m 1 "http://127.0.0.1:$p/health" 2>/dev/null | head -c 80
echo
done
echo
echo "[gostack] persistent stack: ports :4110+:4211-:4219 · bucket=$PERSISTENT_BUCKET"
echo "[gostack] smoke harnesses: ports :3110+:3211-:3219 · bucket=lakehouse-go-primary"
echo "[gostack] shared: chatd at :3220 (read-mostly LLM dispatch)"
echo "[gostack] shared: chatd :3220 + validatord :3221 (no S3 / JSONL-only state)"
echo "[gostack] tear down via: pkill -f 'bin/persistent-'"
+28 -10
View File
@@ -70,6 +70,7 @@ roster_path = "$ROSTER"
default_max_iterations = 3
default_max_tokens = 4096
chat_timeout_secs = 240
session_log_path = "$TMP/sessions.jsonl"
EOF
poll_health() {
@@ -115,13 +116,15 @@ if [ "$STATUS" != "422" ]; then
echo " ✗ expected 422; got $STATUS body=$(cat /tmp/playbook_422.json)"
exit 1
fi
KIND="$(jq -r '.Kind' /tmp/playbook_422.json)"
FIELD="$(jq -r '.Field' /tmp/playbook_422.json)"
if [ "$KIND" != "schema" ] || [ "$FIELD" != "fingerprint" ]; then
echo " ✗ expected kind=schema field=fingerprint; got kind=$KIND field=$FIELD"
# Rust serde-tagged-enum shape (parity with crates/validator):
# {"Schema":{"field":"fingerprint","reason":"..."}}
VARIANT="$(jq -r 'keys[0]' /tmp/playbook_422.json)"
FIELD="$(jq -r '.Schema.field' /tmp/playbook_422.json)"
if [ "$VARIANT" != "Schema" ] || [ "$FIELD" != "fingerprint" ]; then
echo " ✗ expected variant=Schema field=fingerprint; got variant=$VARIANT field=$FIELD"
exit 1
fi
echo " ✓ playbook missing fingerprint → 422 schema/fingerprint"
echo " ✓ playbook missing fingerprint → 422 Schema/fingerprint"
# 4. /v1/validate fill with phantom candidate → 422 Consistency
echo "[validatord-smoke] /v1/validate fill with phantom candidate → 422:"
@@ -132,12 +135,13 @@ if [ "$STATUS" != "422" ]; then
echo " ✗ expected 422; got $STATUS body=$(cat /tmp/fill_422.json)"
exit 1
fi
KIND="$(jq -r '.Kind' /tmp/fill_422.json)"
if [ "$KIND" != "consistency" ]; then
echo " ✗ expected kind=consistency; got kind=$KIND body=$(cat /tmp/fill_422.json)"
# Rust serde-tagged-enum shape: {"Consistency":{"reason":"..."}}
VARIANT="$(jq -r 'keys[0]' /tmp/fill_422.json)"
if [ "$VARIANT" != "Consistency" ]; then
echo " ✗ expected variant=Consistency; got variant=$VARIANT body=$(cat /tmp/fill_422.json)"
exit 1
fi
echo " ✓ phantom candidate W-PHANTOM → 422 consistency"
echo " ✓ phantom candidate W-PHANTOM → 422 Consistency"
# 5. /v1/validate unknown kind → 400
echo "[validatord-smoke] /v1/validate unknown kind → 400:"
@@ -150,4 +154,18 @@ if [ "$STATUS" != "400" ]; then
fi
echo " ✓ unknown kind → 400"
echo "[validatord-smoke] PASS — 5/5 probes through gateway :3110"
# 6. Session log: every /v1/validate hit was a single-shot validation
# (not iterate), so the session log isn't populated yet. Verify it
# exists as a path (logger initialized) — the iterate handler is
# where rows actually land. This proves the wiring is in place
# even if the smoke doesn't drive a live iteration end-to-end.
LOG_PATH="$TMP/sessions.jsonl"
echo "[validatord-smoke] session log path wired:"
grep -q "validatord session log" /tmp/validatord.log || {
echo " ✗ expected validatord to log 'validatord session log' on startup"
grep validatord /tmp/validatord.log
exit 1
}
echo " ✓ session_log_path=$LOG_PATH announced at startup (rows land on /v1/iterate calls)"
echo "[validatord-smoke] PASS — 6/6 probes through gateway :3110"
+64
View File
@@ -0,0 +1,64 @@
# Real-shape coordinator queries — generated from fill_events.parquet
# (real-shape demand data; queries built mechanically from event rows).
# Source: /home/profit/lakehouse/data/datasets/fill_events.parquet (123 rows total, rows [10,60) emitted, styles=[need])
#
# Styles:
# need: 'Need N {role}{s} in {city} {state} starting at {at} for {client}'
# — matches scripts/playbook_lift's extractRoleFromNeed regex
# client_first: '{client} needs N {role}{s} in {city} {state} at {at}'
# looking: 'Looking for N {role}{s} at {client} in {city} {state} for {at} shift'
# shorthand: 'N {role}{s} {city} {state} {at} {client}'
#
# Only 'need' currently extracts a role. The other three test the
# substrate's bleed behavior when the role gate is silently disabled.
Need 1 Loader in Kansas City MO starting at 17:30 for Cornerstone Fabrication
Need 2 Assemblers in Cincinnati OH starting at 14:30 for Great Lakes Mfg
Need 1 Forklift Operator in Lexington KY starting at 08:30 for Vanguard Components, deadline 2026-05-20
Need 2 Assemblers in Flint MI starting at 08:30 for Centennial Packaging
Need 2 Welders in Indianapolis IN starting at 10:00 for Northland Logistics
Need 2 Material Handlers in Cincinnati OH starting at 13:00 for Great Lakes Mfg
Need 3 Pickers in Flint MI starting at 17:00 for Centennial Packaging
Need 3 Packers in Indianapolis IN starting at 09:00 for Heritage Foods, deadline 2026-05-04
Need 3 Assemblers in Columbus OH starting at 17:30 for Riverfront Steel
Need 5 Machine Operators in Cleveland OH starting at 14:30 for Apex Warehouse
Need 2 Assemblers in Grand Rapids MI starting at 13:00 for Cornerstone Fabrication, deadline 2026-05-21
Need 2 Pickers in Akron OH starting at 10:30 for Summit Industrial
Need 3 Quality Techs in Lexington KY starting at 12:30 for Keystone Plastics, deadline 2026-06-14
Need 4 Assemblers in Gary IN starting at 12:00 for Heritage Foods
Need 3 Packers in Toledo OH starting at 16:00 for Cornerstone Fabrication, deadline 2026-06-02
Need 4 Warehouse Associates in Fort Wayne IN starting at 13:00 for Cornerstone Fabrication, deadline 2026-05-17
Need 4 Assemblers in Columbus OH starting at 13:00 for Midway Distribution
Need 2 Shipping Clerks in Chicago IL starting at 17:00 for Midway Distribution
Need 2 Machine Operators in Chicago IL starting at 11:00 for Midway Distribution
Need 3 CNC Operators in Grand Rapids MI starting at 10:00 for Parallel Machining, deadline 2026-06-14
Need 1 Warehouse Associate in Lexington KY starting at 09:30 for Keystone Plastics, deadline 2026-06-14
Need 2 Material Handlers in Gary IN starting at 11:00 for Heritage Foods
Need 5 Assemblers in Fort Wayne IN starting at 16:30 for Midway Distribution, deadline 2026-06-09
Need 1 Loader in Cincinnati OH starting at 08:00 for Summit Industrial
Need 3 Quality Techs in Columbus OH starting at 12:00 for Riverfront Steel
Need 1 Machine Operator in Columbus OH starting at 09:30 for Riverfront Steel
Need 3 Machine Operators in Madison WI starting at 12:00 for Great Lakes Mfg, deadline 2026-05-24
Need 2 Material Handlers in Kansas City MO starting at 11:30 for Parallel Machining
Need 3 Loaders in Flint MI starting at 16:00 for Parallel Machining
Need 2 Welders in Louisville KY starting at 13:00 for Horizon Supply, deadline 2026-06-04
Need 1 CNC Operator in Flint MI starting at 10:30 for Horizon Supply
Need 1 Material Handler in Columbus OH starting at 15:30 for Riverfront Steel
Need 2 Forklift Operators in Louisville KY starting at 14:30 for Cornerstone Fabrication, deadline 2026-06-08
Need 2 Warehouse Associates in Chicago IL starting at 10:00 for Northland Logistics
Need 2 Material Handlers in Gary IN starting at 15:00 for Parallel Machining
Need 1 Forklift Operator in Grand Rapids MI starting at 10:00 for Cornerstone Fabrication, deadline 2026-05-21
Need 2 Pickers in Louisville KY starting at 12:30 for Cornerstone Fabrication, deadline 2026-06-08
Need 2 Loaders in Indianapolis IN starting at 17:30 for Midway Distribution
Need 1 Shipping Clerk in Indianapolis IN starting at 12:30 for Northland Logistics
Need 2 Assemblers in Cincinnati OH starting at 08:00 for Keystone Plastics, deadline 2026-05-26
Need 5 Quality Techs in Kansas City MO starting at 11:30 for Summit Industrial, deadline 2026-05-23
Need 2 Machine Operators in Gary IN starting at 10:00 for Heritage Foods
Need 1 Packer in Chicago IL starting at 09:30 for Midway Distribution
Need 2 Pickers in Lexington KY starting at 17:30 for Vanguard Components, deadline 2026-05-20
Need 2 Maintenance Techs in Grand Rapids MI starting at 17:00 for Pioneer Assembly, deadline 2026-05-20
Need 1 Material Handler in Detroit MI starting at 10:30 for Summit Industrial
Need 1 Welder in Akron OH starting at 15:00 for Summit Industrial
Need 1 Shipping Clerk in Cincinnati OH starting at 13:30 for Summit Industrial
Need 5 Packers in Indianapolis IN starting at 10:30 for Midway Distribution
Need 1 Forklift Operator in Louisville KY starting at 10:30 for Cornerstone Fabrication, deadline 2026-06-08