Compare commits

..
Author SHA1 Message Date
root 640db8c63c Phase 26 — Mem0 upsert + Letta geo hot cache
Closes the two remaining 2026-era memory findings. Both are
optimizations per J's framing — not load-bearing, but good data
hygiene + future-proofing at scale.

MEM0 UPSERT (data hygiene):
Before: /seed always appended. A scenario re-running the same
operation on the same day wrote duplicate entries, inflating the
playbook corpus with near-identical rows.

Now: upsert_entry(new) inspects existing non-retired entries and
decides ADD / UPDATE / NOOP:
  ADD     → no matching (operation, day, city, state) tuple, append
  UPDATE  → match exists with different names → merge (union, stable
            order), refresh timestamp, keep original playbook_id so
            citations stay valid
  NOOP    → match exists with identical names → skip, return id

Day-granularity keying on timestamp YYYY-MM-DD means intraday
re-seeds dedup but tomorrow's same-operation is a fresh ADD. Retired
entries don't block new seeds — they're out of scope anyway.

Seed endpoint returns {outcome: {mode, playbook_id, merged_names?},
entries_after}. Append=false retains old replace-all semantics.

5 unit tests pass: first_seed_is_add, identical_reseed_is_noop,
same_day_different_names_updates_and_merges, different_day_same_op_is_add,
retired_entry_doesnt_block_new_seed.

Live verified: three successive seeds with (Alice), (Alice),
(Alice, Bob) left entry count unchanged at 1936 with merged names
{Alejandro, Lauren, Alice, Bob}. Previously would have been 3
appends.

LETTA GEO HOT CACHE (scale primitive):
Added geo_index: HashMap<(city_lower, state_upper), Vec<usize>>
alongside PlaybookMemoryState. Rebuilt on every mutation: set_entries,
retire_one, retire_on_schema_drift, upsert_entry, load_from_storage.

compute_boost_for_filtered_with_role now uses the index for O(1) geo
lookup instead of scanning all entries. At current scale (1.9K) the
scan was sub-ms; at 100K+ the scan becomes the dominant cost. The
hot cache future-proofs without adding an LRU abstraction.

Retired entries excluded from index; valid_until still checked on the
hot path since it can elapse between rebuilds.

Owns cloned PlaybookEntries in the geo_filtered vector so the state
read-lock is released before cosine scoring — avoids lock contention
on the scoring path.

Memory-findings progress: 5 of 5 shipped.
  ✓ Multi-strategy parallel retrieval (Phase 19 refinement)
  ✓ Input normalization + unified /memory/query (Phase 24 TS)
  ✓ Zep validity windows (Phase 25)
  ✓ Mem0 UPSERT (Phase 26 today)
  ✓ Letta geo hot cache (Phase 26 today)

All 18 playbook_memory tests pass.
2026-04-21 00:24:05 -05:00
root 138592dc56 Spec v2 — all chapters aligned with Phases 19-25
Full audit pass on devop.live/lakehouse/spec. Five chapters were
stale, one had an outright incorrect line. Scope was bigger than
ch6 alone — J asked "you want to update all" and the honest answer
was yes.

Ch 1 (Repository layout):
- mcp-server row gains /memory/query, /models/matrix, /system/summary,
  observer.ts with :3800 listener
- tests/multi-agent/ row lists all new files: kb.ts, normalize.ts,
  memory_query.ts, gen_scenarios.ts, gen_staffer_demo.ts, and the
  colocated unit tests (kb.test.ts, normalize.test.ts)
- NEW config/ row documents models.json as the 5-tier matrix
- data/ row enumerates the four learning-loop directories:
  _kb/, _playbook_lessons/, _observer/, _chunk_cache/

Ch 3 (Measurement & indexing):
- NEW "Model matrix (Phase 20)" subsection — 5-tier table (T1 hot /
  T2 review / T3 overview / T4 strategic / T5 gatekeeper), per-tier
  primary model, frequency, the think:false mechanical finding
  called out with the 650-token reasoning-budget example
- NEW "Continuation primitive (Phase 21)" paragraph
- NEW "Per-staffer tool_level (Phase 23)" section with full/local/
  basic/minimal mapping and the 46pt fill-rate delta from the 36-run
  demo

Ch 7 (Scale story):
- FIX: playbook_memory growth bullet was claiming "No TTL or merge
  policy" — Phase 25 added retirement via valid_until +
  schema_fingerprint + /retire endpoint. Rewritten to name current
  state (1936 entries, active vs retired split exposed).

Ch 8 (Error surfaces):
- Five new rows added to the failure-mode table:
  * Zero-supply city → cloud rescue (Phase 22 item B) with the
    Gary IN → South Bend IN concrete example
  * LLM truncation → generateContinuable (Phase 21)
  * Schema migration → /vectors/playbook_memory/retire (Phase 25)
  * Observer unreachable → scenario silent-skip + append journal
    survivability

Ch 9 (Per-staffer context):
- NEW "Staffer identity + competence-weighted retrieval (Phase 23)"
  section with the competence_score formula and findNeighbors
  weighted_score
- NEW "Auto-discovered reliable-performer labels" section naming
  Rachel D. Lewis (18 endorsements) and Angela U. Ward (19) as
  concrete output of 36-run demo

Ch 10 (A day in the life):
- Added 17:15 timeline entry — Kim using /memory/query with natural
  language, regex normalizer extracting role/city/count in 0ms
- 17:00 entry updated to mention KB indexing + pathway recommendation
  + observer stream
- 22:00 entry updated to mention detectErrorCorrections nightly scan

Ch 11 (Known limits & non-goals):
- FIX: "playbook_memory compaction" bullet rewritten since retirement
  is now wired; reframed as the honest Mem0 UPDATE/NOOP gap
- Added Letta hot cache deferred item with honest "cheap at 1.9K,
  will bite at 100K" framing
- Added Chunking cache (Phase 21 Rust port) deferred item
- Added Observer → autotune feedback wire deferred item (Phase 26+)

Footer bumped v1 2026-04-20 → v2 2026-04-21 with Phase list.

Verified all updates live on devop.live/lakehouse/spec.
2026-04-21 00:16:41 -05:00
root e0a843d1a5 Phase 25 — validity windows + playbook retirement
Addresses the load-bearing memory gap J flagged: playbook entries
had timestamps but no retirement semantic. When a schema migration
changed a column or a seasonal contract ended, stale playbooks kept
boosting candidates silently. Zep 2026-era finding — temporal
validity is the single highest-value memory-hygiene primitive.

SCHEMA (PlaybookEntry gains four optional fields, serde default):
  schema_fingerprint  — SHA-256 over dataset (column, type) tuples at
                        seed time. Missing = legacy entry, never
                        auto-retired on drift.
  valid_until         — RFC3339 hard expiry. compute_boost skips
                        entries past this moment.
  retired_at          — Set by retire_one or retire_on_schema_drift.
                        Retired entries excluded from all boost
                        calculations but kept in journal.
  retirement_reason   — Human-readable: "schema_drift: ...",
                        "expired: ...", "manual: ..."

RETRIEVAL PATH (compute_boost_for_filtered_with_role):
  Before geo+cosine, active_entries filter removes anything retired
  OR past valid_until. Uses chrono::Utc::now() once per call, no per-
  entry clock queries.

NEW METHODS on PlaybookMemory:
  retire_one(playbook_id, reason)
  retire_on_schema_drift(city, state, current_fp, reason) — idempotent,
    scopes by (city, state) so a Nashville migration doesn't touch
    Chicago. Skips legacy entries with no fingerprint.
  status_counts() -> (total, retired, failures)

HTTP ENDPOINTS:
  POST /vectors/playbook_memory/retire
    {playbook_id, reason}                        → retire by id
    {city, state, current_schema_fingerprint, reason} → schema drift
  GET  /vectors/playbook_memory/status
    {total, active, retired, failures}

SEED REQUEST extended with optional schema_fingerprint + valid_until
so the orchestrator (scenario.ts) can pass the current schema hash
when seeding, without a round trip through catalogd.

UNIT TESTS (5/5 pass): retire_one_marks_entry_and_persists,
retired_entries_do_not_boost, expired_valid_until_is_skipped,
schema_drift_retires_mismatched_fingerprints_only,
schema_drift_skips_other_cities.

LIVE VERIFIED: /status on current state = 1936 entries, 43 failures.
POST /retire with a sample playbook_id → "retired":1, /status now
reports active=1935, retired=1.

Memory-findings progress: 3 of 5 shipped.
  ✓ Multi-strategy parallel retrieval (Phase 19 refinement)
  ✓ Input normalization + unified /memory/query (Phase 24 TS)
  ✓ Zep-style validity windows (Phase 25, tonight)
  ⏳ Mem0 UPDATE / DELETE / NOOP ops (dedup same-(op,date) seeds)
  ⏳ Letta working-memory hot cache (not biting at 1.5K entries)
2026-04-21 00:11:02 -05:00
root 3fb3a60da4 Spec ch6 rewrite — 3 learning paths → 7 + honest gap list
J flagged the spec out of alignment with what's built. Ch6 now
reflects the full current architecture:

- Path 1 (playbook boost) — formula kept; geo+role prefilter
  refinement called out with measured 14× citation lift
- Path 2 (pattern discovery) — unchanged
- Path 3 (autotune agent) — unchanged
- Path 4 (KB + pathway recommender) — Phase 22, file layout
  documented
- Path 5 (cloud rescue on failure) — Phase 22 item B, verified
  stress_01 example cited
- Path 6 (staffer competence-weighted retrieval) — Phase 23,
  competence_score formula included, cross-staffer auto-
  discovered worker labels (Rachel D. Lewis 18× endorsements)
- Path 7 (observer outcome ingest) — Phase 24, :3800 HTTP
  listener + ops.jsonl append journal

Input normalizer + unified /memory/query surface documented as
the "seamless with whatever input" answer, with the 319ms
natural-language latency number.

Honest gaps kept visible in the spec itself, not hidden:
- Zep validity windows (most load-bearing remaining)
- Mem0 UPDATE/DELETE/NOOP ops
- Letta working-memory hot cache

Live at https://devop.live/lakehouse/spec#ch6 after service
restart. Verified post-deploy: geo+role prefilter, 14× delta,
validity windows gap all present in served HTML.
2026-04-21 00:03:06 -05:00
root 52561d10d3 Input normalizer + unified memory query — "seamless with whatever input"
J asked directly: "did we implement our memory findings so that our
knowledge base and our configuration playbook [work] seamlessly with
whatever input they're given?" Honest answer tonight was "one of five
findings shipped, normalizer is the blocker." This closes that gap.

NORMALIZER (tests/multi-agent/normalize.ts):
Accepts structured JSON, natural language, or mixed. Returns canonical
NormalizedInput { role, city, state, count, client, deadline, intent,
confidence, extraction_method, missing_fields } for any downstream
consumer.

Three-tier path:
  1. Structured fast-path — already-shaped input skips LLM
  2. Regex path — "need 3 welders in Nashville, TN" parses without LLM.
     City/state parser tightened to 1-3 capitalized words + "in {city}"
     anchor preference + case-exact full-state-name variants to prevent
     "Forklift Operators in Chicago" being captured as the city name
  3. LLM fallback — qwen3 local with think:false + 400 max_tokens for
     inputs the regex can't handle

Unit tests (tests/multi-agent/normalize.test.ts): 9/9 pass. Covers
structured fast-path, misplacement→rescue intent, state-name→abbrev
conversion, regex extraction from natural language, plural role +
full state name edge case, rescue intent keyword precedence, partial
input reporting missing fields, empty object fallthrough, async/sync
parity on clean inputs.

UNIFIED MEMORY QUERY (tests/multi-agent/memory_query.ts):
One function, five parallel fan-outs, one bundle returned:
  - playbook_workers — hybrid_search via gateway with use_playbook_memory
  - pathway_recommendation — KB recommender for this sig
  - neighbor_signatures — K-NN sigs weighted by staffer competence
  - prior_lessons — T3 overseer lessons filtered by city/state
  - top_staffers — competence-sorted leaderboard
  - discovered_patterns — top workers endorsed across past playbooks
    for this (role, city, state)
  - latency_ms — per-source + total
Every branch is best-effort: one source down doesn't break the bundle.

HTTP ENDPOINT (mcp-server/index.ts):
  POST /memory/query with body {input: <anything>} → MemoryQueryResult
Returns the same shape the TS function does. Typed with types.ts for
future UI consumption.

VERIFIED:
  curl POST /memory/query with structured {role,city,state,count}
    → extraction_method=structured, 10 playbook workers, top score 0.878
  curl POST /memory/query with "I need 3 welders in Nashville, TN"
    → extraction_method=regex (no LLM call), 319ms total, 8 endorsements
      for Lauren Gomez auto-discovered as top Nashville Welder

Honest remaining gaps (documented for next phase):
  - Mem0 ADD/UPDATE/DELETE/NOOP — we still only ADD + mark_failed
  - Zep validity windows — playbook entries have timestamps but no
    retirement semantic
  - Letta working-memory / hot cache — every query scans all 1560
    playbook entries
  - Memory profiles / scoped queries — global pool, no per-staffer
    private subsets

2 of 5 findings now shipped (multi-strategy retrieval in Rust, input
normalization + unified query in TS). The remaining 3 are architectural
additions queued as Phase 25 items — validity windows first since it's
the most load-bearing for long-running systems.
2026-04-20 23:59:05 -05:00
root b95dd86556 Phase 24 — observer HTTP ingest + scenario outcome streaming
Closes the gap J flagged: observer wraps MCP:3700, scenarios hit
gateway:3100 directly, observer idle at 0 ops across 3600+ cycles.
Now scenarios POST per-event outcomes to observer's new HTTP ingest
on :3800, observer consumes them alongside MCP-wrapped ops, ERROR_
ANALYZER and PLAYBOOK_BUILDER loops see the full picture.

observer.ts:
- Bun.serve() HTTP listener on OBSERVER_PORT (default 3800):
  GET /health    — basic + ring depth
  GET /stats     — total / success / failure / by_source / recent
                   scenario ops digest
  POST /event    — accept scenario outcome, shape it into ObservedOp
                   with source="scenario" + staffer_id + sig_hash +
                   event_kind + role/city/state + rescue flags
- recordExternalOp() — shared ring-buffer insert so the main analyzer
  + playbook builder don't care where the op came from
- ObservedOp extended with provenance fields

persistOp() FIX — old path POSTed to /ingest/file?name=observed_operations
which REPLACES the dataset (flagged in feedback_ingest_replace_semantics.md).
Every op was silently wiping all prior ops. Replaced with append to
data/_observer/ops.jsonl so the historical trace is durable across
analyzer cycles and process restarts.

scenario.ts:
- OBSERVER_URL env (default http://localhost:3800)
- postObserverEvent() helper with 2s AbortSignal.timeout so observer
  being down doesn't block scenario flow
- Per-event POST after ctx.results.push(result), carrying staffer_id,
  sig_hash (via imported computeSignature), event_kind + role + city
  + state + count + rescue_attempted / rescue_succeeded + truncated
  output_summary

VERIFIED:
  curl POST /event → {"accepted":true,"ring_size":1}
  curl GET /stats → {"total":1,"successes":1,"by_source":{"scenario":1},
    "recent_scenario_ops":[{...staffer_id,kind,role}]}

Final v3 demo leaderboard (9 runs per staffer, cumulative 3 batches):
  James (local):   92.9% fill, 36.8 cites, score 0.775 — RANK 1
  Maria (full):    81.0% fill, 26.2 cites, score 0.727
  Sam (basic):     61.9% fill, 28.2 cites, score 0.640
  Alex (minimal):  59.5% fill, 32.2 cites, score 0.631
Honest finding: Alex has MORE citations than Sam despite NO T3 and NO
rescue. Playbook inheritance alone is firing hardest when overseer is
absent. The 59.5% fill rate (up from 0% when qwen2.5 was executor)
proves cloud-exec + playbook inheritance is the floor the architecture
delivers.

Local gpt-oss:20b T3 outperforms cloud gpt-oss:120b T3 by 12pt fill
rate on this workload — cloud overseer paying latency+variance for
no measurable gain, worth flagging in next models.json tune.
2026-04-20 23:49:30 -05:00
root 137aed64fb Coherence pass — PRD/PHASES updates, config snapshot wired, unit tests
J flagged the audit: "make sure everything flows coherently, no
pseudocode or unnecessary patches or ignoring any particular part of
what we built." This is that pass.

PRD.md updates:
- Phase 19 refinement block — geo-filter + role-prefilter WIRED with
  citation density numbers (0.32 → 1.38, and 2 → 28 on same scenario).
- Phase 20 rewrite — mistral dropped, qwen3.5 + qwen3 local hot path,
  think:false as the key mechanical finding, kimi-k2.6 upgrade path.
- Phase 21 status block — think plumbing + cloud executor routing
  added after original commit.
- Phase 22 item B (cloud rescue) — pivot sanitizer, rescue verified
  1/3 on stress_01.
- Phase 23 NEW — staffer identity + tool_level + competence-weighted
  retrieval + kb_staffer_report. Auto-discovered worker labels called
  out with real numbers (Rachel Lewis 12× across 4 staffers).
- Phase 24 NEW — Observer/Autotune integration gap DOCUMENTED, not
  fixed. Observer has been idle at 0 ops for 3600+ cycles because
  scenarios hit gateway:3100 directly, bypassing MCP:3700 which the
  observer wraps. This is the honest "we're not using it in these
  tests" signal J surfaced. Fix deferred; gap visible now.

PHASES.md:
- Appended Phases 20-23 as checked, Phase 24 as unchecked gap.
- Updated footer count: 102 unit tests across all layers.
- Latest line updated with 14× citation lift + 46.4pt tool-asymmetry
  finding.

scenario.ts:
- snapshotConfig() was defined but never called. Now fires at every
  scenario start with a stable sha256 hash over the active model set +
  tool_level + cloud flags. config_snapshots.jsonl finally populates,
  which the error_corrections diff path needs to work correctly.

kb.test.ts (new): 4 signature invariant tests — stability across
unrelated fields (date, contract, staffer), sensitivity to role/city/
count changes, digest shape. All pass under `bun test`.

service.rs: 6 Rust extractor tests for extract_target_geo +
extract_target_role — basic, missing-state-returns-none, word
boundary (civilian != city), multi-word role, absent role, quoted
value parse. All pass under `cargo test -p vectord --lib extractor_tests`.

Dangling items now honestly documented rather than silently pending:
- Chunking cache (config/models.json SPEC, not wired) — flagged
- Playbook versioning (SPEC, not wired) — flagged
- Observer integration (WIRED but disconnected) — new Phase 24
2026-04-20 23:29:13 -05:00
root ad0edbe29c Cloud kimi-k2.5 executor for weak tiers + multi-strategy playbook retrieval
Two coupled changes from the 2026 agent-memory research + tool
asymmetry findings.

SCENARIO (weak-tier cloud substitute):
qwen2.5 collapsed to 0/14 across the basic/minimal tool_levels.
Replace with cloud kimi-k2.5 on Ollama Cloud — same family as k2.6
(pro-tier locked today, on J's upgrade path). Plumb cloud flag
through ACTIVE_EXECUTOR_CLOUD / ACTIVE_REVIEWER_CLOUD into
generateContinuable so executor/reviewer can route to cloud when
tool_level requires. think:false supported by Kimi family.

Tool level mapping (revised):
  full     — qwen3.5 local + qwen3 local + cloud gpt-oss:120b T3 + rescue
  local    — qwen3.5 local + qwen3 local + local gpt-oss:20b T3 + rescue
  basic    — kimi-k2.5 cloud + qwen3 local + local T3, no rescue
  minimal  — kimi-k2.5 cloud + qwen3 local, no T3, no rescue.
             Playbook inheritance alone on the decision path.

This is the honest version of J's "minimal tools still works via
inheritance" hypothesis — with the executor no longer broken at the
tokenizer level, we can actually measure whether playbook retrieval
substitutes for missing overseers.

PLAYBOOK_MEMORY (multi-strategy retrieval):
Zep / Mem0 research shows multi-strategy rerank (semantic + keyword +
graph + temporal) outperforms single-strategy cosine. Lakehouse now
has a two-tier:

  1. Exact (role, city, state) match: skip cosine, assign similarity=1.0,
     take up to top_k/2+1 slots. These are identity-class neighbors —
     the strongest possible signal.
  2. Cosine fallback within the same (city, state) but different role:
     fills remaining slots.

Exposed as compute_boost_for_filtered_with_role(target_geo, target_role).
Backwards-compatible: compute_boost_for_filtered forwards with role=None
so existing callers keep their current behavior.

Service.rs wires both: extract_target_geo and extract_target_role pull
from the executor's SQL filter. grab_eq_value is factored out of
extract_target_geo so both lookups share one parser. Diagnostic log
now prints target_role alongside target_geo for every hybrid_search:

  playbook_boost: boosts=88 sources=39 parsed=39 matched=5
    target_geo=Some(("Nashville", "TN")) target_role=Some("Welder")

Verified: Nashville Welder query returns 5/10 boosted workers in
top_k with clean role+geo provenance.

Research sources: atlan.com Agent Memory Frameworks 2026, Mem0 paper
(arxiv 2504.19413), Zep/Graphiti LongMemEval comparison, ossinsight
Agent Memory Race 2026.

kimi-k2.6 on current key returns 403 — pro-tier upgrade required.
kimi-k2.5 is the substitute today; swap to k2.6 by renaming one line
in applyToolLevel once the subscription lands.
2026-04-20 23:20:07 -05:00
root 5e89407939 Phase 23 refinement — per-staffer tool_level variance
Staffer.tool_level now controls which subsystems a specific run gets:

  full     — qwen3.5 + qwen3 + cloud T3 + cloud rescue
  local    — qwen3.5 + qwen3 + local gpt-oss:20b T3 + rescue
  basic    — qwen2.5 + qwen2.5 + local T3, no rescue
  minimal  — qwen2.5 + qwen2.5, NO T3, NO rescue. Playbook
             inheritance only.

applyToolLevel() mutates module-scoped ACTIVE_* slots each run from the
env defaults, so prior staffer's overrides never leak. Hot-path code
reads ACTIVE_EXECUTOR / ACTIVE_REVIEWER / ACTIVE_T3_DISABLED /
ACTIVE_OVERVIEW_CLOUD / ACTIVE_RETRY_ON_FAIL instead of the baked
constants.

The architectural question this answers: does playbook_memory
inheritance carry enough knowledge to let a weakly-tooled coordinator
still produce usable outcomes? "Minimal" Alex runs qwen2.5 exec + no
reviewer overseer + no cloud rescue. If Alex still fills events at a
reasonable rate, the playbook system is the real knowledge carrier —
the senior stack is nice-to-have, not the sine qua non.

Demo personas mapped:
  Maria (senior, 48mo, full)
  James (mid, 14mo, local)
  Sam (junior, 4mo, basic)
  Alex (trainee, 1mo, minimal)

Same 3 contracts (Nashville downtown, Joliet warehouse, Indianapolis
assembly) across all four → 12 runs. KB + kb_staffer_report.py
leaderboard already wired; competence_score will now reflect real tool
asymmetry instead of LLM sampling variance.
2026-04-20 22:50:05 -05:00
root 6b71c8e9b2 Phase 23 — contract terms + staffer identity + competence-weighted retrieval
Matrix-index the "who handled this" dimension so top staffers become
the training signal and juniors inherit their playbooks automatically
via the boost pipeline. Auto-discovered indicators emerge from
comparing trajectories across staffers on similar contracts — that was
always the architectural point; this wires the last piece.

ContractTerms:
- deadline, budget_total_usd, budget_per_hour_max, local_bonus_per_hour,
  local_bonus_radius_mi, fill_requirement ("paramount" | "preferred")
- Attached to ScenarioSpec, propagated into T3 checkpoint + cloud
  rescue prompts so cloud reasons about trade-offs (pivot within bonus
  radius first; respect per-hour cap; split across cities when
  fill_requirement=paramount).

Staffer:
- {id, name, tenure_months, role: senior|mid|junior|trainee}
- On ScenarioSpec; logged at scenario start; attached to KB outcome
- Recomputed StafferStats written to data/_kb/staffers.jsonl after
  every run: total_runs, fill_rate, avg_turns, avg_citations,
  rescue_rate, competence_score.
- Competence formula: 0.45*fill_rate + 0.20*turn_efficiency +
  0.20*citation_density + 0.15*rescue_rate. Normalized to 0..1.

findNeighbors now returns weighted_score = cosine × best_staffer_competence
(floored at 0.3 so high-similarity low-competence neighbors still
surface). pathway_recommender prompt shows the top staffer's identity
so cloud knows WHOSE playbook it's synthesizing from.

Demo infrastructure:
- tests/multi-agent/gen_staffer_demo.ts: 4 personas (Maria senior,
  James mid, Sam junior, Alex trainee) × 3 contracts (Nashville Welder,
  Joliet Warehouse, Indianapolis Assembly). 12 scenarios total.
- scripts/run_staffer_demo.sh: runs the 12 sequentially with
  LH_OVERVIEW_CLOUD=1. Post-run calls kb_staffer_report.py.
- scripts/kb_staffer_report.py: leaderboard + cross-staffer worker
  overlap (names endorsed by ≥2 staffers → auto-discovered high-value
  workers). Top vs bottom differential.

gen_scenarios.ts (Phase 22 generator) also now emits contract terms
on 70% of generated specs — future KB batches populate with realistic
constraint patterns instead of bare role+city+count.

Stress scenario from item A intentionally NOT the production test.
Real staffing has constraints; Nashville contract + staffer demo is
the honest test of whether the architecture produces measurable
differential between coordinator skill levels.

Demo batch launched — 12 runs × ~3min each ≈ 40min unattended. Report
emitted after batch.
2026-04-20 22:16:09 -05:00
root a7fc8e2256 Item B — cloud-rescue retry on event failure
When a scenario event fails (drift abort or other error) and
LH_RETRY_ON_FAIL is on (default when cloud T3 is enabled), ask cloud
for a concrete pivot — new city, role, or count — then re-run the
event with the remediation's fields. Capped at 1 retry per event so a
genuinely-impossible scenario can't burn budget.

requestCloudRemediation(event, result):
- Feeds the same diagnostic bundle T3 checkpoints get (SQL filters,
  row counts, SQL errors, reviewer drift reasons, gap signals).
- Prompt demands structured JSON: {retry, new_city, new_role,
  new_count, rationale}.
- Cloud is instructed to pivot to NEAREST alternate city when
  zero-supply detected, broaden role when uniquely scarce, reduce
  count when clearly unachievable, or return retry=false when no
  pivot seems viable.

EventResult additions:
- retry_attempt, retry_remediation (with rationale + cloud_model +
  duration), retry_result (full inner result shape), original_event.
- If retry succeeded, it becomes the primary result and original_event
  preserves what was attempted first. If retry also failed, the
  primary stays the failure and retry is recorded alongside.

Sanitizer on cloud output: model sometimes emits "Hammond, IN" in
new_city with "IN" in a non-existent new_state field, producing
"Hammond, IN, IN" downstream. Split new_city on comma, take first
token as city, extract state if present after the comma. Original
event's state is the fallback.

VERIFIED on stress_01.json with LH_OVERVIEW_CLOUD=1:
  Without rescue (item A baseline):  1/5 events ok
  With rescue (item B):              3/5 events ok
Gary IN misplacement: drift → cloud proposed South Bend IN → retry
filled 1/1. Rationale stored in retry_remediation for forensics.

Known limits surfaced (future work):
- City-field mangling failed one rescue before the sanitizer landed;
  next run will use the fix.
- Cloud picks alternate cities without knowing ground-truth supply.
  Flint → Saginaw pivoted but Saginaw also had sparse Welders.
  Future: expose a /vectors/supply-estimate endpoint cloud can consult
  before proposing a pivot.
2026-04-20 22:01:45 -05:00
root c21b261877 Item A — stress scenario + enriched T3 diagnostic prompt
Proves cloud passthrough works end-to-end AND fixes the diagnostic
quality problem that first run surfaced.

STRESS SCENARIO (tests/multi-agent/scenarios/stress_01.json):
Five genuinely hard events with varied failure modes:
- Gary, IN 5× Electrician: ZERO supply (city not in workers_500k)
- Peoria, IL 8× Safety Coordinator: scarce role, initial pool only 5
- Flint, MI 3× Welder: ZERO supply
- Grand Rapids, MI 4× Tool & Die Maker: scarce but solvable
- Gary, IN 1× Electrician misplacement: repeats event 1's impossibility

FIRST RUN (stress v1) — cloud passthrough works, diagnosis vague:
  T3 checkpoint: "Potential drift flags for upcoming role"
  Lesson: "Before dispatching, query pool status. Update turn counter..."
Generic tactical advice that doesn't address the real problem.
Root cause: T3 prompt only saw outcome summary, not the raw
SQL/pool/drift signals the executor had in its log.

DIAGNOSTIC FIX:
- Added LogEntry[] `sharedLog` parameter to runAgentFill so the caller
  retains the trace even when runAgentFill throws drift-abort.
- EventResult gained `diagnostic_log` field populated on both OK and
  FAIL paths.
- extractDiagnostics() pulls SQL filters, hybrid_search row counts,
  SQL errors, and reviewer drift notes from the log.
- Checkpoint prompt now includes FAILURE FORENSICS block for failed
  events: SQL filters attempted, row counts, errors, drift reasons,
  and an explicit teaching note about zero-supply detection.
- Cross-day lesson prompt flags each event with [ZERO-SUPPLY: pivot
  city needed] tag when drift reasons mention "no match"/"no
  candidates"/"0 rows". PRIORITY clause in the prompt tells the model
  its lesson MUST name alternate cities when that tag appears.

SECOND RUN (stress v2 with enriched prompt) — cloud diagnosis sharp:
  T3 after Flint: risk="Zero candidate supply for Welder in Flint"
                  hint="search Welder×3 in Saginaw, MI (≈30 mi) or
                        expand role to Metal Fabricator"
  T3 after Gary:  risk="Zero supply for Electrician in Gary, IN"
                  hint="Pivot to Chicago, IL (≈40 min); broaden to
                        Electrical Technician within 60 min radius"
  Lesson: specific, per-city, with distances, role-broadening
  fallback, and pre-loading strategy — actionable for item B retry.

Cloud 120b call latencies consistent: 4.8-8.0s per prompt. Cloud
passthrough proven under stress.

Fill outcomes unchanged (1/5 — correct rejection of three impossible
events + one propagating JSON emission edge case on retry pivot
reasoning). The knowledge to rescue them now exists in the lesson;
item B wires the retry.
2026-04-20 21:54:29 -05:00
root a663698571 Item 3 — geo-filtered playbook boost; diagnostic logging
ROOT CAUSE (found via instrumentation, not hunch):
After a 20-scenario corpus batch, only 6/40 successful (role, city)
combos ever triggered playbook_memory citations on subsequent runs.
Added `playbook_boost:` tracing::info! line in vectord::service to log
boost map size vs candidate pool vs match count. One query revealed:

  boosts=170 sources=50 parsed=50 matched=0

170 endorsed workers came back from compute_boost_for — but zero were
in the 50-candidate Toledo pool. The boost map was pulling globally-
ranked semantic neighbors (top-100 playbooks across ALL cities),
dominated by Kansas City / Chicago / Detroit forklift playbooks the
Toledo SQL filter would never admit. The mechanism was correct at the
per-playbook level; the problem was pool intersection.

FIX (surgical, not cap-tuning):
- playbook_memory::compute_boost_for_filtered(): accepts optional
  (city, state) filter. When set, skips playbooks from other geos
  BEFORE cosine-ranking, so top-k is within the target city.
- Backwards-compatible: compute_boost_for() calls the filtered variant
  with None — existing callers unchanged.
- service::hybrid_search(): extracts target (city, state) from the
  executor's SQL filter via a small parser (extract_target_geo),
  passes to compute_boost_for_filtered.

VERIFIED:
  Before fix: boosts=170 sources=50 parsed=50 matched=0   (0% hit)
  After fix:  boosts=36  sources=50 parsed=50 matched=11  (22% hit)
Top-k=10 now has 7/10 boosted workers with 2-3 citations each.
Boost values 0.075-0.113 on cosine scores 0.67-0.74 — meaningful
reorder without saturation.

scripts/kb_measure.py:
Aggregator that reads data/_kb/*.jsonl and playbooks/*/results.json,
reports fill rate, citation density, recommender confidence trend,
and zero-citation-ok combos (item 3 target signal). Used to measure
before/after on bigger batches.

Diagnostic logging stays — the class of "boosts computed but not
matched" bug can recur if the SQL filter format ever drifts, and
without the counter it's invisible. Every hybrid_search with
use_playbook_memory=true now logs its boost stats.
2026-04-20 21:35:04 -05:00
root 330cb90f99 Lift k cap, drop ornamental reason field, scenario generator
ITEM 1 — k CAP + REASON FIELD
The hybrid_search default k was hard-coded to 10. For multi-fill events
(5× expansion, 4× emergency) that's pool=10 → propose 5-of-10, half
the candidates become the answer with no room for rejection. Executor
prompt now instructs k to scale with target_count: k = max(count*5, 20),
cap 80. Default helper bumped 10 → 20.

Fill.reason dropped from required to optional. Nothing downstream ever
consumed it — resolveWorkerIds, sealSale, retrospective all use
candidate_id and name. Models loved to write 100-150 char justifications
per fill; on 4+ fills that blew the JSON budget before the structure
closed. Test 1 run result after this change: FIRST EVER 5/5 on the
Riverfront Steel scenario, 13 total turns across 5 events. The event
that failed last run (emergency 4×Loader with truncated reason-field
continuation) now clears in 2 turns.

Progression:
  mistral baseline:                  0/5
  qwen3.5 + continuation + think:false: 4/5
  qwen3.5 + k=20 + no-reason:        5/5 ✓

ITEM 2 — SCENARIO GENERATOR (NOT YET TESTED E2E)
tests/multi-agent/gen_scenarios.ts emits N deterministic ScenarioSpecs
with varied clients (15 companies), cities (20 Midwest cities known
to exist in workers_500k), role mixes (14 industrial staffing roles,
weighted realistic), and event sequences. Each gets a unique sig_hash
so the KB populates with distinct neighbor signatures.

scripts/run_kb_batch.sh runs all generated specs sequentially against
scenario.ts, logs per-scenario outcomes, and reports KB state at the
end. Each run takes ~2-4min; 20-30 scenarios = 1-2hr unattended.

Next: test the generator+batch on a small N (3-5) to verify KB
populates correctly and pathway recommendations start getting neighbor
signal instead of cold-starts. Then item 3 (Rust re-weighting of
hybrid_search by playbook_memory success).
2026-04-20 20:31:34 -05:00
root 9c1400d738 Phase 22 — Internal Knowledge Library (KB)
Meta-layer over Phase 19 playbook_memory. Phase 19 answers "which
WORKERS worked for this event"; KB answers "which CONFIG worked for
this playbook signature" — model choice, budget hints, pathway notes,
error corrections.

tests/multi-agent/kb.ts:
- computeSignature(): stable sha256 hash of the (kind, role, count,
  city, state) tuple sequence. Same scenario shape → same sig.
- indexRun(): extracts sig, embeds spec digest via sidecar, appends
  outcome record, upserts signature to data/_kb/signatures.jsonl.
- findNeighbors(): cosine-ranks the k most-similar signatures from
  prior runs for a target spec.
- detectErrorCorrections(): scans outcomes for same-sig fail→succeed
  pairs, diffs the model set, logs to error_corrections.jsonl.
- recommendFor(): feeds target digest + k-NN neighbors + recent
  corrections to the overview model, gets back a structured JSON
  recommendation (top_models, budget_hints, pathway_notes), appends
  to pathway_recommendations.jsonl. JSON-shape constrained so the
  executor can inherit it mechanically.
- loadRecommendation(): at scenario start, pulls newest rec matching
  this sig (or nearest).

scenario.ts:
- Reads KB recommendation at startup (alongside prior lessons).
- Injects pathway_notes into guidanceFor() executor context.
- After retrospective, indexes the run + synthesizes next rec.

Cold-start behavior: first run with no history writes a low-confidence
"no prior data" rec so the signal that something was attempted is
captured. Second run gets "low confidence, 0 neighbors" until a third
distinct sig gives the embedder something to compare against — hence
the upcoming scenario generator.

VERIFIED:
- data/_kb/ populated after one scenario run: 1 outcome (sig=4674…,
  4/5 ok, 16 turns total), 1 signature, 2 recs (cold + post-run).
- Recommendation JSON-parsed cleanly from gpt-oss:20b overview model.

PRD Phase 22 added with file layout, cycle description, and the
rationale for file-based MVP → Rust port progression that matches
how Phase 21 primitives shipped.

What's NOT here yet (batched follow-ups per J's request, tested
between each):
- Lift the k=10 hybrid_search cap to adaptive k=max(count*5, 20)
- Scenario generator to bulk-populate KB with varied signatures
- Rust re-weighting: push playbook_memory success signal INTO
  hybrid_search scoring, not just post-hoc boost
2026-04-20 20:27:12 -05:00
root 0c4868c191 qwen3.5 executor + continuation primitive + think:false
Three coupled fixes that together turned the Riverfront Steel scenario
from 0/5 (mistral) to 4/5 (qwen3.5) with T3 flagging real staffing
concerns rather than linter advice.

MODEL SWAP
- Executor: mistral → qwen3.5:latest (9.7B, 262K ctx, thinking).
  mistral's decoder emitted malformed JSON on complex SQL filters
  regardless of prompt; J called it — stop using mistral.
- Reviewer: qwen2.5 → qwen3:latest (40K ctx)
- Applied to scenario.ts, orchestrator.ts, network_proving.ts,
  run_e2e_rated.ts

CONTINUATION PRIMITIVE (agent.ts)
- generateContinuable(): empty-response → geometric backoff retry;
  truncated-JSON → continue from partial as scratchpad; bounded by
  budget cap + max_continuations. No more "bump max_tokens until it
  stops truncating" tourniquet.
- generateTreeSplit(): map-reduce for oversized input corpora with
  running scratchpad digest, reduce pass for final synthesis.
- Empty text no longer throws — it's a signal to continuable that
  thinking ate the budget.

think:false FOR HOT PATH
- qwen3.5 burned ~650 tokens of hidden thinking for trivial JSON
  emission. For executor/reviewer/draft: think:false. For T3/T4/T5
  overseers: thinking stays on (that's the point).
- Sidecar generate endpoint accepts `think` bool, passes through to
  Ollama's /api/generate.

VERIFIED OUTCOMES
Riverfront Steel 2026-04-21, qwen3.5+continuable+think:false:
  08:00 baseline_fill  3/3  4 turns
  10:30 recurring      2/2  3 turns (1 playbook citation)
  12:15 expansion      0/5  drift-aborted (5-fill orchestration
                            problem, separate work)
  14:00 emergency      4/4  3 turns (1 citation)
  15:45 misplacement   1/1  3 turns
  → T3 caught Patrick Ross double-booking across events
  → T3 flagged forklift cert drift on the event that failed
  → Cross-day lesson proposed "maintain buffer of ≥3 emergency
    candidates, pre-fetch certs for expansion, booking system
    cross-check" — real staffing advice, not generic linter output

PRD PHASE 21 rewritten to reflect the actual primitive shape (two-
call map-reduce with scratchpad glue) instead of the tourniquet
approach originally documented. Rust port queued for next sprint.

scripts/ab_t3_test.sh: A/B harness that chains B→C→D runs and emits
tests/multi-agent/playbooks/ab_scorecard.json.
2026-04-20 20:19:02 -05:00
62 changed files with 5979 additions and 141 deletions
+16 -7
View File
@@ -22,9 +22,9 @@
"t1_hot": {
"purpose": "Per tool call — SQL generation, hybrid_search, sql(). Runs 50-200 times per scenario. Latency-sensitive.",
"kind": "local_fast",
"primary": { "model": "mistral:latest", "provider": "ollama_local", "context_window": 32768 },
"primary": { "model": "qwen3.5:latest", "provider": "ollama_local", "context_window": 262144 },
"fallback": { "model": "qwen2.5:latest", "provider": "ollama_local", "context_window": 32768 },
"max_tokens": 800,
"max_tokens": 1000,
"temperature": 0.3,
"never_route_cloud": true,
"context_budget": {
@@ -34,14 +34,14 @@
"safety_margin": 2000,
"overflow_policy": "summarize_oldest_tool_results_via_t3"
},
"rationale": "Mistral produces valid JSON reliably. Qwen2.5 is the consensus reviewer. Known flakiness on 5-fill + misplacement events — do NOT mask by upgrading; route to T3 for post-hoc review instead."
"rationale": "qwen3.5:latest is a 9.7B thinking model with 262K context that emits clean JSON (verified 2026-04-21 after mistral A/B wipeout). Thinking budget requires max_tokens ≥ 400; we hold 1000 to keep propose_done payloads intact. qwen2.5 stays as fallback."
},
"t2_review": {
"purpose": "Per step consensus — executor ↔ reviewer loop critique. 5-14 calls per event.",
"kind": "local_balanced",
"primary": { "model": "qwen2.5:latest", "provider": "ollama_local", "context_window": 32768 },
"fallback": { "model": "qwen3:latest", "provider": "ollama_local", "context_window": 40960 },
"max_tokens": 600,
"primary": { "model": "qwen3:latest", "provider": "ollama_local", "context_window": 40960 },
"fallback": { "model": "qwen2.5:latest", "provider": "ollama_local", "context_window": 32768 },
"max_tokens": 800,
"temperature": 0.3,
"never_route_cloud": true,
"context_budget": {
@@ -72,7 +72,8 @@
"t4_strategic": {
"purpose": "Daily playbook board re-ranking, weekly gap audit, pattern discovery across accumulated playbooks. 1-10 calls per day.",
"kind": "thinking_cloud_large",
"primary": { "model": "qwen3.5:397b", "provider": "ollama_cloud", "context_window": 131072 },
"primary": { "model": "kimi-k2.6", "provider": "ollama_cloud", "context_window": 200000 },
"secondary": { "model": "qwen3.5:397b", "provider": "ollama_cloud", "context_window": 131072 },
"fallback": { "model": "glm-4.7", "provider": "ollama_cloud", "context_window": 131072 },
"local_fallback": { "model": "gpt-oss:20b", "provider": "ollama_local", "context_window": 131072 },
"max_tokens": 2000,
@@ -108,6 +109,14 @@
}
},
"embeddings": {
"_description": "Vector embedding models for hybrid search + playbook_memory similarity. Always local — embedding calls are too high-volume for cloud. qwen3-embedding is the new primary (2026-04-21); nomic-embed-text-v2-moe is the MoE alternative for corpus-scale embedding; original nomic-embed-text stays as legacy fallback while vectord indexes are being re-embedded.",
"primary": { "model": "qwen3-embedding", "provider": "ollama_local", "dim": 1024, "context_window": 32768 },
"alternate": { "model": "nomic-embed-text-v2-moe", "provider": "ollama_local", "dim": 768, "context_window": 2048, "notes": "MoE variant — better for long documents, slower per-call, roughly matches nomic-embed-text on short text." },
"legacy": { "model": "nomic-embed-text:latest", "provider": "ollama_local", "dim": 768, "context_window": 8192, "notes": "Used by existing vectord indexes. Do NOT switch live until indexes are rebuilt, or hybrid search recall will crater." },
"migration_plan": "Re-embed workers_500k (and all production indexes) with qwen3-embedding, keep the old index under a _v1 suffix for rollback, flip the profile pointer only after recall benchmarks match or exceed legacy. Touches vectord::agent + autotune."
},
"context_management": {
"_description": "Rule zero: NEVER call a model with more tokens than its context_window minus safety_margin. Every call goes through the budget checker first. If over budget → chunk, summarize, or escalate. This is the stability floor.",
"token_estimator": {
+569 -6
View File
@@ -85,6 +85,34 @@ pub struct PlaybookEntry {
/// state can omit it on first load and have a later embed() fill in.
#[serde(default)]
pub embedding: Option<Vec<f32>>,
/// Schema fingerprint captured at seed time — SHA-256 hex of the
/// target dataset's (column_name, type) tuples. When the dataset's
/// schema changes (column rename, type change, drop), entries
/// seeded against the old schema are considered stale and get
/// skipped by `compute_boost_for_filtered_with_role` unless the
/// caller passes `allow_stale: true`. Optional so historical
/// entries without a fingerprint (ingested before this field
/// existed) degrade to "never stale" rather than getting
/// silently zeroed. Phase 25 (2026-04-21).
#[serde(default)]
pub schema_fingerprint: Option<String>,
/// Optional hard expiry. When set and now() > valid_until, the
/// entry is skipped. Used for playbooks that were known to be
/// time-limited at seed time (seasonal hires, temporary contracts).
#[serde(default)]
pub valid_until: Option<String>,
/// Set by `retire()` — auto-retirement when schema drift detected,
/// or manual via POST /vectors/playbook_memory/retire. Entries with
/// this set are excluded from all boost calculations; they remain
/// in the journal for forensic purposes.
#[serde(default)]
pub retired_at: Option<String>,
/// Human-readable retirement reason. Examples:
/// "schema_drift: workers_500k 2026-05-03 added column X"
/// "expired: valid_until 2026-05-01 elapsed"
/// "manual: operator requested via POST /retire"
#[serde(default)]
pub retirement_reason: Option<String>,
}
/// A recorded failure — worker who didn't deliver on a contract.
@@ -121,12 +149,48 @@ pub struct BoostEntry {
pub citations: Vec<String>, // playbook_ids that endorsed this worker
}
/// Phase 26 — what happened during an upsert. The seed endpoint
/// returns this shape so the caller sees whether its write was a new
/// entry, a merge, or a dedup'd no-op.
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "mode", rename_all = "lowercase")]
pub enum UpsertOutcome {
/// New playbook appended. Carries the new playbook_id.
Added(String),
/// Existing same-day entry updated. Playbook_id unchanged; names
/// merged (union, original order preserved, new names appended).
Updated {
playbook_id: String,
merged_names: Vec<String>,
},
/// Identical same-day entry already exists; nothing changed.
/// Returns the stable playbook_id so caller still has a reference.
Noop(String),
}
/// Return YYYY-MM-DD from an RFC3339 timestamp. Falls back to the
/// first 10 chars if parse fails — tolerant for legacy entries that
/// stored a bare date.
fn day_key(ts: &str) -> String {
chrono::DateTime::parse_from_rfc3339(ts)
.map(|t| t.format("%Y-%m-%d").to_string())
.unwrap_or_else(|_| ts.chars().take(10).collect())
}
/// Live handle passed around the service. Clone-cheap (all state is
/// inside one Arc<RwLock>).
#[derive(Clone)]
pub struct PlaybookMemory {
state: Arc<RwLock<PlaybookMemoryState>>,
store: Arc<dyn ObjectStore>,
/// Phase 26 — hot geo index: (city_lower, state_upper) → sorted
/// Vec<entry_idx>. Rebuilt on every mutation of `entries`. At
/// current scale (1.9K entries) a full scan is sub-ms; at 100K+
/// the index skips the scan for geo-filtered queries, which is
/// the dominant code path. Letta-style working memory with a real
/// LRU is overkill here — entries are bounded and fit in RAM;
/// what we need is a precomputed seek, not a bounded cache.
geo_index: Arc<RwLock<HashMap<(String, String), Vec<usize>>>>,
}
impl PlaybookMemory {
@@ -134,9 +198,28 @@ impl PlaybookMemory {
Self {
state: Arc::new(RwLock::new(PlaybookMemoryState::default())),
store,
geo_index: Arc::new(RwLock::new(HashMap::new())),
}
}
/// Rebuild the geo index from scratch. Called by every mutation
/// helper after persist succeeds. O(n) scan of entries; at current
/// scale ~40µs. Skips retired entries — they never participate in
/// boost filtering, so indexing them would just waste lookups.
async fn rebuild_geo_index(&self) {
let state = self.state.read().await;
let mut idx: HashMap<(String, String), Vec<usize>> = HashMap::new();
for (i, e) in state.entries.iter().enumerate() {
if e.retired_at.is_some() { continue; }
let (Some(city), Some(st)) = (&e.city, &e.state) else { continue; };
let key = (city.to_ascii_lowercase(), st.to_ascii_uppercase());
idx.entry(key).or_default().push(i);
}
drop(state);
let mut guard = self.geo_index.write().await;
*guard = idx;
}
/// Best-effort load from primary storage. Missing = empty memory; the
/// first `/rebuild` call will hydrate it.
pub async fn load_from_storage(&self) -> Result<usize, String> {
@@ -148,6 +231,7 @@ impl PlaybookMemory {
.map_err(|e| format!("parse playbook_memory state: {e}"))?;
let n = persisted.entries.len();
*self.state.write().await = persisted;
self.rebuild_geo_index().await;
tracing::info!("playbook_memory: loaded {n} entries from {STATE_KEY}");
Ok(n)
}
@@ -164,7 +248,160 @@ impl PlaybookMemory {
s.entries = entries;
s.last_rebuilt_at = chrono::Utc::now().timestamp_millis();
drop(s);
self.persist().await
self.persist().await?;
self.rebuild_geo_index().await;
Ok(())
}
/// Phase 25 — retire a specific playbook by id. Idempotent; repeat
/// calls don't overwrite the first reason. Persisted.
pub async fn retire_one(&self, playbook_id: &str, reason: &str) -> Result<bool, String> {
let mut touched = false;
{
let mut state = self.state.write().await;
for e in state.entries.iter_mut() {
if e.playbook_id == playbook_id && e.retired_at.is_none() {
e.retired_at = Some(chrono::Utc::now().to_rfc3339());
e.retirement_reason = Some(reason.to_string());
touched = true;
break;
}
}
}
if touched {
self.persist().await?;
self.rebuild_geo_index().await;
}
Ok(touched)
}
/// Phase 25 — retire every entry matching (city, state) whose
/// schema_fingerprint doesn't match the current one. Entries with
/// no fingerprint (legacy) are skipped — caller can use
/// `retire_by_scope` for blanket retirement.
pub async fn retire_on_schema_drift(
&self,
city: &str,
state_code: &str,
current_fingerprint: &str,
reason: &str,
) -> Result<usize, String> {
let mut count = 0;
{
let mut state = self.state.write().await;
let now = chrono::Utc::now().to_rfc3339();
for e in state.entries.iter_mut() {
if e.retired_at.is_some() { continue; }
let Some(ec) = &e.city else { continue; };
let Some(es) = &e.state else { continue; };
if !ec.eq_ignore_ascii_case(city) || !es.eq_ignore_ascii_case(state_code) { continue; }
match &e.schema_fingerprint {
Some(fp) if fp != current_fingerprint => {
e.retired_at = Some(now.clone());
e.retirement_reason = Some(reason.to_string());
count += 1;
}
_ => {}
}
}
}
if count > 0 {
self.persist().await?;
self.rebuild_geo_index().await;
}
Ok(count)
}
/// Stats accessor for the /status endpoint and tests.
pub async fn status_counts(&self) -> (usize, usize, usize) {
let state = self.state.read().await;
let total = state.entries.len();
let retired = state.entries.iter().filter(|e| e.retired_at.is_some()).count();
let failures = state.failures.len();
(total, retired, failures)
}
/// Phase 26 — Mem0-style upsert. Decides ADD / UPDATE / NOOP based
/// on whether a non-retired entry with the same operation already
/// exists for the same day. Three outcomes:
///
/// ADD → no matching entry, append the new one
/// UPDATE → existing same-day entry found, merge endorsed_names
/// (union, preserving order) and refresh timestamp.
/// Playbook_id is kept stable so citations from prior
/// boost calls stay valid.
/// NOOP → existing same-day entry with identical
/// endorsed_names. Skip — no duplicates accumulate.
///
/// "Same day" keyed on YYYY-MM-DD of the entry's timestamp so
/// intraday re-seeding of the same operation dedups but tomorrow's
/// seeding for the same operation lands as a fresh ADD (which is
/// correct — a new day is a new event).
pub async fn upsert_entry(&self, new_entry: PlaybookEntry) -> Result<UpsertOutcome, String> {
let new_day = day_key(&new_entry.timestamp);
let new_names_sorted = {
let mut v = new_entry.endorsed_names.clone();
v.sort();
v
};
let mut state = self.state.write().await;
// Find a non-retired entry with same operation + day + city +
// state. Operation string alone would false-match across days
// or across cities that happen to share role+count; city+state
// is already parsed out of operation so adding them to the key
// costs nothing.
let mut existing_idx: Option<usize> = None;
for (i, e) in state.entries.iter().enumerate() {
if e.retired_at.is_some() { continue; }
if e.operation != new_entry.operation { continue; }
if day_key(&e.timestamp) != new_day { continue; }
if e.city != new_entry.city || e.state != new_entry.state { continue; }
existing_idx = Some(i);
break;
}
match existing_idx {
None => {
let pid = new_entry.playbook_id.clone();
state.entries.push(new_entry);
drop(state);
self.persist().await?;
self.rebuild_geo_index().await;
Ok(UpsertOutcome::Added(pid))
}
Some(i) => {
let mut existing_names_sorted = state.entries[i].endorsed_names.clone();
existing_names_sorted.sort();
if existing_names_sorted == new_names_sorted {
// NOOP — identical data, just report the existing id
let pid = state.entries[i].playbook_id.clone();
Ok(UpsertOutcome::Noop(pid))
} else {
// UPDATE — merge names (union, stable order).
let existing = state.entries.get_mut(i).ok_or("index invalidated")?;
let mut merged: Vec<String> = existing.endorsed_names.clone();
for n in &new_entry.endorsed_names {
if !merged.contains(n) { merged.push(n.clone()); }
}
existing.endorsed_names = merged.clone();
existing.timestamp = new_entry.timestamp.clone();
// Keep original playbook_id + embedding + schema fingerprint.
// Refresh embedding only if the caller passed a non-None
// one (indicates the text shape changed).
if new_entry.embedding.is_some() {
existing.embedding = new_entry.embedding.clone();
}
if new_entry.schema_fingerprint.is_some() {
existing.schema_fingerprint = new_entry.schema_fingerprint.clone();
}
let pid = existing.playbook_id.clone();
drop(state);
self.persist().await?;
self.rebuild_geo_index().await;
Ok(UpsertOutcome::Updated { playbook_id: pid, merged_names: merged })
}
}
}
}
pub async fn entry_count(&self) -> usize {
@@ -213,6 +450,48 @@ impl PlaybookMemory {
query_embedding: &[f32],
top_k_playbooks: usize,
base_weight: f32,
) -> HashMap<(String, String, String), BoostEntry> {
self.compute_boost_for_filtered(query_embedding, top_k_playbooks, base_weight, None).await
}
/// Same as `compute_boost_for` but only considers playbooks whose
/// (city, state) matches the caller's target. This is the honest
/// fix for the "boosts=170 matched=0" pathology: globally-ranked
/// semantic neighbors include playbooks from every city the query
/// could never reach via its SQL filter. When the caller knows the
/// target geo, restricting here collapses noise and raises the
/// endorsed-worker hit rate. Pass None for the original behavior.
///
/// 2026-04-21 — added after a corpus-density batch of 25 runs
/// showed only 6/40 successful (role, city) combos ever triggered
/// a citation on subsequent runs. Diagnostic logging proved the
/// boost map had 170 keys but the 50-candidate pool matched 0.
pub async fn compute_boost_for_filtered(
&self,
query_embedding: &[f32],
top_k_playbooks: usize,
base_weight: f32,
target_geo: Option<(&str, &str)>,
) -> HashMap<(String, String, String), BoostEntry> {
self.compute_boost_for_filtered_with_role(query_embedding, top_k_playbooks, base_weight, target_geo, None).await
}
/// Variant that also accepts a target role for pre-filtering.
/// Multi-strategy retrieval: exact (role, city, state) matches skip
/// cosine entirely and earn the maximum boost, since identity on
/// those three fields is the strongest possible similarity signal.
/// Remaining entries (within the same city+state but different
/// role, or unknown role) go through the normal cosine path as a
/// fallback. This addresses the 2026 agent-memory finding that
/// multi-strategy parallel retrieval with rerank outperforms
/// single-strategy semantic search.
pub async fn compute_boost_for_filtered_with_role(
&self,
query_embedding: &[f32],
top_k_playbooks: usize,
base_weight: f32,
target_geo: Option<(&str, &str)>,
target_role: Option<&str>,
) -> HashMap<(String, String, String), BoostEntry> {
let state = self.state.read().await;
let entries = state.entries.clone();
@@ -224,13 +503,90 @@ impl PlaybookMemory {
}
drop(state);
// Brute-force cosine. Empty / missing embeddings just skip.
let mut scored: Vec<(f32, &PlaybookEntry)> = entries
// Phase 25 validity-window filtering. Happens before geo+cosine
// so retired/expired entries never reach the ranking pool. We
// don't mutate the state here (can't grab a write lock inside
// a read-heavy hot path); retirement_at auto-retirement is a
// separate background pass. Here we just skip anything already
// retired, and entries whose valid_until has elapsed.
let now = chrono::Utc::now();
let active_entries: Vec<&PlaybookEntry> = entries
.iter()
.filter_map(|e| e.embedding.as_ref().map(|v| (cosine(query_embedding, v), e)))
.filter(|e| {
if e.retired_at.is_some() { return false; }
if let Some(vu) = &e.valid_until {
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(vu) {
if now > parsed.with_timezone(&chrono::Utc) { return false; }
}
}
true
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.truncate(top_k_playbooks.max(1));
// Pre-filter by target_geo (city, state) before cosine. Phase 26
// hot cache — use the geo index (O(1) key lookup) instead of a
// linear scan of all entries. Retired entries are excluded from
// the index; valid_until is still checked here since it can
// elapse between index rebuilds.
//
// Owned entries (not references) because the state read-lock is
// released between here and the cosine step — we don't want to
// hold a read lock across the scoring work.
let geo_filtered: Vec<PlaybookEntry> = if let Some((tc, ts)) = target_geo {
let key = (tc.to_ascii_lowercase(), ts.to_ascii_uppercase());
let index = self.geo_index.read().await;
let Some(idxs) = index.get(&key) else { return HashMap::new(); };
let idxs = idxs.clone();
drop(index);
let state = self.state.read().await;
idxs.into_iter()
.filter_map(|i| state.entries.get(i))
.filter(|e| {
if e.retired_at.is_some() { return false; }
if let Some(vu) = &e.valid_until {
if let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(vu) {
if now > parsed.with_timezone(&chrono::Utc) { return false; }
}
}
true
})
.cloned()
.collect()
} else {
active_entries.into_iter().cloned().collect()
};
// Multi-strategy: split the geo-filtered pool into (exact role
// match) vs (other). Exact matches skip cosine — they're already
// the strongest signal possible. References into geo_filtered
// which owns the entries.
let mut exact_matches: Vec<&PlaybookEntry> = Vec::new();
let mut cosine_pool: Vec<(f32, &PlaybookEntry)> = Vec::new();
let role_needle = target_role
.map(|r| format!("fill: {} ", r).to_ascii_lowercase());
for e in geo_filtered.iter() {
let is_exact = role_needle.as_ref()
.map(|needle| e.operation.to_ascii_lowercase().contains(needle))
.unwrap_or(false);
if is_exact {
exact_matches.push(e);
} else if let Some(v) = &e.embedding {
cosine_pool.push((cosine(query_embedding, v), e));
}
}
cosine_pool.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
// Allocate top_k across the two pools — exact matches first,
// then cosine fills the rest. Identity beats similarity.
let exact_take = exact_matches.len().min(top_k_playbooks.max(1) / 2 + 1);
let cosine_take = top_k_playbooks.saturating_sub(exact_take);
let mut scored: Vec<(f32, &PlaybookEntry)> = exact_matches
.into_iter()
.take(exact_take)
.map(|e| (1.0_f32, e))
.collect();
scored.extend(cosine_pool.into_iter().take(cosine_take));
let now = chrono::Utc::now();
let mut boosts: HashMap<(String, String, String), BoostEntry> = HashMap::new();
@@ -691,6 +1047,14 @@ pub async fn rebuild(
city,
state,
embedding: None,
// Rebuild doesn't know fingerprints; historical entries
// get no retirement signal until a seed with a
// fingerprint supersedes them or the operator calls
// /retire manually.
schema_fingerprint: None,
valid_until: None,
retired_at: None,
retirement_reason: None,
}
})
.collect();
@@ -869,6 +1233,10 @@ mod tests {
city: Some("Toledo".into()),
state: Some("OH".into()),
embedding: Some(vec![1.0, 0.0, 0.0]),
schema_fingerprint: None,
valid_until: None,
retired_at: None,
retirement_reason: None,
})
.collect();
tokio::runtime::Runtime::new().unwrap().block_on(async {
@@ -881,3 +1249,198 @@ mod tests {
});
}
}
#[cfg(test)]
mod validity_window_tests {
use super::*;
use object_store::memory::InMemory;
fn mkentry(id: &str, city: &str, state: &str, fingerprint: Option<String>, valid_until: Option<String>) -> PlaybookEntry {
PlaybookEntry {
playbook_id: id.into(),
operation: format!("fill: Welder x1 in {city}, {state}"),
approach: "hybrid".into(),
context: "test".into(),
timestamp: chrono::Utc::now().to_rfc3339(),
endorsed_names: vec!["Test Worker".into()],
city: Some(city.into()),
state: Some(state.into()),
embedding: Some(vec![1.0, 0.0, 0.0]),
schema_fingerprint: fingerprint,
valid_until,
retired_at: None,
retirement_reason: None,
}
}
#[tokio::test]
async fn retire_one_marks_entry_and_persists() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
pm.set_entries(vec![mkentry("pb-1", "Nashville", "TN", None, None)]).await.unwrap();
let touched = pm.retire_one("pb-1", "manual test").await.unwrap();
assert!(touched);
let (total, retired, _) = pm.status_counts().await;
assert_eq!(total, 1);
assert_eq!(retired, 1);
// Second retirement is a no-op
let second = pm.retire_one("pb-1", "again").await.unwrap();
assert!(!second);
}
#[tokio::test]
async fn retired_entries_do_not_boost() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let e1 = mkentry("pb-active", "Nashville", "TN", None, None);
let mut e2 = mkentry("pb-retired", "Nashville", "TN", None, None);
e2.retired_at = Some(chrono::Utc::now().to_rfc3339());
pm.set_entries(vec![e1, e2]).await.unwrap();
let boosts = pm.compute_boost_for_filtered_with_role(
&[1.0, 0.0, 0.0], 100, 0.5,
Some(("Nashville", "TN")), Some("Welder")
).await;
// Only the active entry should surface. Both endorse the same
// name so we check citation count isn't doubled — presence of
// the retired playbook id in citations would mean it slipped
// through.
let entry = boosts.get(&("Nashville".into(), "TN".into(), "Test Worker".into())).unwrap();
assert!(!entry.citations.contains(&"pb-retired".to_string()));
assert!(entry.citations.contains(&"pb-active".to_string()));
}
#[tokio::test]
async fn expired_valid_until_is_skipped() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let past = (chrono::Utc::now() - chrono::Duration::days(1)).to_rfc3339();
let future = (chrono::Utc::now() + chrono::Duration::days(1)).to_rfc3339();
let e_expired = mkentry("pb-expired", "Nashville", "TN", None, Some(past));
let e_alive = { let mut e = mkentry("pb-alive", "Nashville", "TN", None, Some(future)); e };
pm.set_entries(vec![e_expired, e_alive]).await.unwrap();
let boosts = pm.compute_boost_for_filtered_with_role(
&[1.0, 0.0, 0.0], 100, 0.5,
Some(("Nashville", "TN")), Some("Welder")
).await;
let entry = boosts.get(&("Nashville".into(), "TN".into(), "Test Worker".into())).unwrap();
assert!(!entry.citations.contains(&"pb-expired".to_string()));
assert!(entry.citations.contains(&"pb-alive".to_string()));
}
#[tokio::test]
async fn schema_drift_retires_mismatched_fingerprints_only() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let e_old = mkentry("pb-old-schema", "Nashville", "TN", Some("fp-v1".into()), None);
let e_new = mkentry("pb-new-schema", "Nashville", "TN", Some("fp-v2".into()), None);
let e_legacy = mkentry("pb-no-fp", "Nashville", "TN", None, None);
pm.set_entries(vec![e_old, e_new, e_legacy]).await.unwrap();
let retired = pm.retire_on_schema_drift("Nashville", "TN", "fp-v2", "test migration").await.unwrap();
// Only pb-old-schema should be retired — pb-new-schema matches,
// pb-no-fp has no fingerprint so it's legacy-safe.
assert_eq!(retired, 1);
let (_, total_retired, _) = pm.status_counts().await;
assert_eq!(total_retired, 1);
}
#[tokio::test]
async fn schema_drift_skips_other_cities() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let e_tn = mkentry("pb-tn", "Nashville", "TN", Some("fp-v1".into()), None);
let e_il = mkentry("pb-il", "Chicago", "IL", Some("fp-v1".into()), None);
pm.set_entries(vec![e_tn, e_il]).await.unwrap();
// Nashville migration shouldn't touch Chicago
let retired = pm.retire_on_schema_drift("Nashville", "TN", "fp-v2", "test").await.unwrap();
assert_eq!(retired, 1);
let (_, r, _) = pm.status_counts().await;
assert_eq!(r, 1);
}
}
#[cfg(test)]
mod upsert_tests {
use super::*;
use object_store::memory::InMemory;
fn mk(op: &str, day: &str, names: &[&str]) -> PlaybookEntry {
PlaybookEntry {
playbook_id: format!("pb-{}-{}", op.replace(' ', "_"), day),
operation: op.into(),
approach: "seed".into(),
context: "test".into(),
timestamp: format!("{day}T12:00:00Z"),
endorsed_names: names.iter().map(|s| s.to_string()).collect(),
city: Some("Nashville".into()),
state: Some("TN".into()),
embedding: Some(vec![1.0, 0.0, 0.0]),
schema_fingerprint: None,
valid_until: None,
retired_at: None,
retirement_reason: None,
}
}
#[tokio::test]
async fn first_seed_is_add() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let e = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
match pm.upsert_entry(e).await.unwrap() {
UpsertOutcome::Added(_) => {}
other => panic!("expected Added, got {:?}", other),
}
assert_eq!(pm.entry_count().await, 1);
}
#[tokio::test]
async fn identical_reseed_is_noop() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let e1 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
pm.upsert_entry(e1).await.unwrap();
let outcome = pm.upsert_entry(e2).await.unwrap();
assert!(matches!(outcome, UpsertOutcome::Noop(_)));
// Still exactly one entry, no duplicate from the re-seed.
assert_eq!(pm.entry_count().await, 1);
}
#[tokio::test]
async fn same_day_different_names_updates_and_merges() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let e1 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith", "Bob Jones"]);
let o1 = pm.upsert_entry(e1).await.unwrap();
let pid = match o1 {
UpsertOutcome::Added(p) => p,
other => panic!("expected Added, got {:?}", other),
};
let o2 = pm.upsert_entry(e2).await.unwrap();
match o2 {
UpsertOutcome::Updated { playbook_id, merged_names } => {
assert_eq!(playbook_id, pid, "Updated should keep original playbook_id");
assert_eq!(merged_names, vec!["Alice Smith".to_string(), "Bob Jones".to_string()]);
}
other => panic!("expected Updated, got {:?}", other),
}
assert_eq!(pm.entry_count().await, 1, "Updated must not create a duplicate");
}
#[tokio::test]
async fn different_day_same_op_is_add() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let e1 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-22", &["Alice Smith"]);
pm.upsert_entry(e1).await.unwrap();
let o2 = pm.upsert_entry(e2).await.unwrap();
assert!(matches!(o2, UpsertOutcome::Added(_)), "different day → fresh ADD");
assert_eq!(pm.entry_count().await, 2);
}
#[tokio::test]
async fn retired_entry_doesnt_block_new_seed() {
let pm = PlaybookMemory::new(Arc::new(InMemory::new()));
let mut e1 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Alice Smith"]);
e1.retired_at = Some(chrono::Utc::now().to_rfc3339());
pm.set_entries(vec![e1]).await.unwrap();
// A new seed on same day should ADD, not merge into the retired one.
let e2 = mk("fill: Welder x2 in Nashville, TN", "2026-04-21", &["Carol Davis"]);
let o = pm.upsert_entry(e2).await.unwrap();
assert!(matches!(o, UpsertOutcome::Added(_)));
assert_eq!(pm.entry_count().await, 2);
}
}
+243 -7
View File
@@ -125,6 +125,8 @@ pub fn router(state: VectorState) -> Router {
.route("/playbook_memory/persist_sql", post(persist_playbook_memory_sql))
.route("/playbook_memory/patterns", post(discover_playbook_patterns))
.route("/playbook_memory/mark_failed", post(mark_playbook_failed))
.route("/playbook_memory/retire", post(retire_playbook_memory))
.route("/playbook_memory/status", get(playbook_memory_status))
.with_state(state)
}
@@ -803,22 +805,65 @@ async fn hybrid_search(
// set. Additive boost on the existing vector score, then re-sort.
if req.use_playbook_memory {
let boost_k = req.playbook_memory_k.unwrap_or(playbook_memory::DEFAULT_TOP_K_PLAYBOOKS);
// Extract target (city, state, role) from the SQL filter so
// compute_boost_for can skip playbooks from other cities AND
// prioritize exact role matches via the multi-strategy path.
// The executor's filter shape is stable:
// `... role = 'Welder' AND city = 'Toledo' AND state = 'OH' ...`.
// Case-insensitive match, tolerant of single quotes.
let target_geo = req.sql_filter.as_deref().and_then(extract_target_geo);
let target_role = req.sql_filter.as_deref().and_then(extract_target_role);
// We embedded the question as `qv` above — reuse it for the
// playbook similarity lookup so we don't double-pay Ollama.
let boosts = state.playbook_memory.compute_boost_for(&qv, boost_k, 0.5).await;
let boosts = state.playbook_memory
.compute_boost_for_filtered_with_role(
&qv,
boost_k,
0.5,
target_geo.as_ref().map(|(c, s)| (c.as_str(), s.as_str())),
target_role.as_deref(),
)
.await;
// Diagnostics for Phase 19 boost pipeline. Logged so item 3
// investigation has ground truth:
// - boosts.len(): how many (city,state,name) keys surfaced for
// this query (0 = playbook_memory found nothing semantically
// similar to the question).
// - parsed: how many candidate chunks parsed cleanly into
// (name,city,state) via parse_worker_chunk.
// - matched: how many parsed keys matched an entry in boosts.
// 2026-04-21 — 20-scenario batch showed 34/40 ok combos never
// got a citation. These counters pin whether the gap is on the
// SIMILARITY side (boosts empty) or the MATCH side (parsed vs
// boosted keys mismatch — e.g. name format drift).
let mut parsed_count = 0usize;
let mut matched_count = 0usize;
for src in sources.iter_mut() {
// Parse "{Name} — {Role} in {City}, {State}. …" chunk. Being
// defensive: chunks from other datasets may not follow this
// exact shape, so absent fields just skip the boost.
if let Some((name, city, state)) = parse_worker_chunk(&src.chunk_text) {
parsed_count += 1;
let key = (city, state, name);
if let Some(entry) = boosts.get(&key) {
src.score += entry.boost;
src.playbook_boost = entry.boost;
src.playbook_citations = entry.citations.clone();
matched_count += 1;
}
}
}
tracing::info!(
"playbook_boost: boosts={} sources={} parsed={} matched={} target_geo={:?} target_role={:?} (query='{}')",
boosts.len(),
sources.len(),
parsed_count,
matched_count,
target_geo,
target_role,
req.question.chars().take(60).collect::<String>(),
);
// Re-rank: boosted scores can flip ordering.
sources.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal));
// Finally trim to the caller's requested top_k — we pulled fetch_k
@@ -2059,6 +2104,56 @@ struct LanceRecallQuery {
/// "{Name} — {Role} in {City}, {State}. Skills: …".
/// Returns None if the chunk doesn't match the shape; callers simply
/// skip the boost for that hit.
/// Extract role from an SQL filter matching `role = 'Welder'` style.
/// Case-insensitive on the column name. Quoted value; quotes not
/// included in returned string.
fn extract_target_role(sql_filter: &str) -> Option<String> {
grab_eq_value(sql_filter, "role")
}
/// Shared equality-value extractor for (city, state, role) lookups.
fn grab_eq_value(src: &str, col: &str) -> Option<String> {
let lower = src.to_ascii_lowercase();
let col_lower = col.to_ascii_lowercase();
let mut search_from = 0usize;
while let Some(off) = lower[search_from..].find(&col_lower) {
let pos = search_from + off;
let prior_ok = pos == 0
|| !lower.as_bytes()[pos - 1].is_ascii_alphanumeric()
&& lower.as_bytes()[pos - 1] != b'_';
let after = pos + col_lower.len();
if !prior_ok || after >= src.len() {
search_from = pos + col_lower.len();
continue;
}
let mut i = after;
while i < src.len() && src.as_bytes()[i] == b' ' { i += 1; }
if i >= src.len() || src.as_bytes()[i] != b'=' { search_from = pos + col_lower.len(); continue; }
i += 1;
while i < src.len() && src.as_bytes()[i] == b' ' { i += 1; }
if i >= src.len() || src.as_bytes()[i] != b'\'' { search_from = pos + col_lower.len(); continue; }
i += 1;
let start = i;
while i < src.len() && src.as_bytes()[i] != b'\'' { i += 1; }
if i > start {
return Some(src[start..i].to_string());
}
search_from = pos + col_lower.len();
}
None
}
/// Pull (city, state) out of a SQL filter that uses
/// `city = 'Toledo' AND state = 'OH'` style equality. Returns None if
/// either is missing — the caller keeps the original global boost map
/// behavior (no geo narrowing). Case-insensitive on the column name
/// so `CITY=` or `City =` also work.
fn extract_target_geo(sql_filter: &str) -> Option<(String, String)> {
let city = grab_eq_value(sql_filter, "city")?;
let state = grab_eq_value(sql_filter, "state")?;
Some((city, state))
}
fn parse_worker_chunk(chunk: &str) -> Option<(String, String, String)> {
// "Name — Role in City, ST. …" → split on "—" then " in " then ","
let (name_part, rest) = chunk.split_once('—')?;
@@ -2092,6 +2187,19 @@ struct SeedPlaybookRequest {
/// seeding is a bootstrap/demo tool, not a rebuild substitute.
#[serde(default = "default_true")]
append: bool,
/// Phase 25 — optional schema_fingerprint captured at seed time.
/// When the underlying dataset's schema changes, any entry whose
/// fingerprint doesn't match the new one is auto-retired via
/// retire_on_schema_drift. Caller-provided so the producer (the
/// scenario driver, the orchestrator) can pass the live fingerprint
/// without the gateway needing a second catalogd round trip.
#[serde(default)]
schema_fingerprint: Option<String>,
/// Phase 25 — optional hard expiry. RFC3339 timestamp. After this
/// moment the entry is skipped during boost computation (not
/// retired, just inactive). Useful for seasonal/temp contracts.
#[serde(default)]
valid_until: Option<String>,
}
/// Bootstrap / test-only: inject a playbook entry directly into
@@ -2116,6 +2224,10 @@ async fn seed_playbook_memory(
timestamp: chrono::Utc::now().to_rfc3339(),
endorsed_names: req.endorsed_names.clone(),
city: None, state: None, embedding: None,
schema_fingerprint: None,
valid_until: None,
retired_at: None,
retirement_reason: None,
};
let text = format!(
"{} | {} | {} | fills: {}",
@@ -2167,18 +2279,40 @@ async fn seed_playbook_memory(
endorsed_names: req.endorsed_names,
city, state: state_,
embedding: Some(emb),
// Phase 25 — seed request may carry a fingerprint; if not, we
// default to None and the entry degrades to "no expiry signal"
// (never auto-retired on drift, but manual retirement still
// works). valid_until + retired_at start None.
schema_fingerprint: req.schema_fingerprint.clone(),
valid_until: req.valid_until.clone(),
retired_at: None,
retirement_reason: None,
};
let mut current = state.playbook_memory.snapshot().await;
// Phase 26 — when append=true (default), route through upsert so
// same-day re-seeds of the same operation merge instead of
// appending duplicates. When append=false, retain the old
// replace-all semantics for callers that want a hard reset.
if req.append {
current.push(new_entry);
} else {
current = vec![new_entry];
match state.playbook_memory.upsert_entry(new_entry).await {
Ok(outcome) => {
let entries_after = state.playbook_memory.entry_count().await;
Ok(Json(serde_json::json!({
"outcome": outcome,
"entries_after": entries_after,
})))
}
if let Err(e) = state.playbook_memory.set_entries(current).await {
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, format!("upsert: {e}"))),
}
} else {
if let Err(e) = state.playbook_memory.set_entries(vec![new_entry]).await {
return Err((StatusCode::INTERNAL_SERVER_ERROR, format!("persist: {e}")));
}
Ok(Json(serde_json::json!({ "playbook_id": pid, "entries_after": state.playbook_memory.entry_count().await })))
Ok(Json(serde_json::json!({
"outcome": { "mode": "replaced", "playbook_id": pid },
"entries_after": state.playbook_memory.entry_count().await,
})))
}
}
async fn rebuild_playbook_memory(
@@ -2302,6 +2436,65 @@ async fn playbook_memory_stats(
}))
}
#[derive(Deserialize)]
struct RetirePlaybookRequest {
/// Retire by playbook_id — exact match, single entry. Used for
/// manual operator retirement via the UI.
#[serde(default)]
playbook_id: Option<String>,
/// Retire by scope — city + state required, with a fingerprint
/// that entries must match to survive. Fingerprint mismatch → retire.
/// Use when a schema migration produces a new fingerprint and
/// historical playbooks need to be auto-retired.
#[serde(default)]
city: Option<String>,
#[serde(default)]
state: Option<String>,
#[serde(default)]
current_schema_fingerprint: Option<String>,
/// Human-readable reason stored on the retired entry.
reason: String,
}
/// Phase 25 retirement endpoint. Two modes:
/// {playbook_id, reason} → retire one
/// {city, state, current_schema_fingerprint, reason} → retire all
/// entries in scope whose
/// fingerprint differs
async fn retire_playbook_memory(
State(state): State<VectorState>,
Json(req): Json<RetirePlaybookRequest>,
) -> impl IntoResponse {
if let Some(id) = &req.playbook_id {
return match state.playbook_memory.retire_one(id, &req.reason).await {
Ok(found) => Ok(Json(serde_json::json!({ "mode": "by_id", "retired": if found { 1 } else { 0 } }))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
};
}
if let (Some(city), Some(state_code), Some(fp)) = (&req.city, &req.state, &req.current_schema_fingerprint) {
return match state.playbook_memory.retire_on_schema_drift(city, state_code, fp, &req.reason).await {
Ok(n) => Ok(Json(serde_json::json!({ "mode": "schema_drift", "retired": n, "city": city, "state": state_code }))),
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
};
}
Err((StatusCode::BAD_REQUEST,
"supply either {playbook_id, reason} or {city, state, current_schema_fingerprint, reason}".into()))
}
/// Phase 25 status endpoint — reports retirement counts so dashboards
/// can show "N playbooks retired (12 from 2026-05 schema migration)".
async fn playbook_memory_status(
State(state): State<VectorState>,
) -> impl IntoResponse {
let (total, retired, failures) = state.playbook_memory.status_counts().await;
Json(serde_json::json!({
"total": total,
"retired": retired,
"active": total.saturating_sub(retired),
"failures": failures,
}))
}
async fn lance_recall_harness(
State(state): State<VectorState>,
Path(index_name): Path<String>,
@@ -2393,3 +2586,46 @@ async fn lance_build_scalar_index(
Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, e)),
}
}
#[cfg(test)]
mod extractor_tests {
use super::*;
#[test]
fn extract_target_geo_basic() {
let f = "role = 'Welder' AND city = 'Toledo' AND state = 'OH' AND CAST(availability AS DOUBLE) > 0.5";
assert_eq!(extract_target_geo(f), Some(("Toledo".into(), "OH".into())));
}
#[test]
fn extract_target_geo_missing_state_returns_none() {
let f = "role = 'Welder' AND city = 'Toledo'";
assert_eq!(extract_target_geo(f), None);
}
#[test]
fn extract_target_geo_word_boundary() {
// "civilian" contains "city" as a substring — must not match.
let f = "civilian_rank = 1 AND city = 'Toledo' AND state = 'OH'";
assert_eq!(extract_target_geo(f), Some(("Toledo".into(), "OH".into())));
}
#[test]
fn extract_target_role_basic() {
let f = "role = 'Welder' AND city = 'Toledo'";
assert_eq!(extract_target_role(f), Some("Welder".into()));
}
#[test]
fn extract_target_role_none_when_absent() {
let f = "city = 'Toledo' AND state = 'OH'";
assert_eq!(extract_target_role(f), None);
}
#[test]
fn extract_target_role_multi_word() {
let f = "role = 'Warehouse Associate' AND city = 'Chicago'";
assert_eq!(extract_target_role(f), Some("Warehouse Associate".into()));
}
}
+63 -5
View File
@@ -230,7 +230,7 @@
- Profile-driven routing: `POST /vectors/profile/{id}/search` auto-routes to Lance when profile.vector_backend=lance
- Auto-migrate + auto-index on activation
- Measured on real 100K × 768d: migrate 0.57s, IVF_PQ build 16.2s (14× faster than HNSW 230s), search 23ms, append 100 rows 3.3ms, doc_id fetch 3.5ms (with scalar btree)
- IVF_PQ recall@10 = 0.805 (HNSW = 1.000) — measured via `/vectors/lance/recall/{idx}` harness
- IVF_PQ recall@10 = 0.805 with Lance's default `nprobes=1` (the hidden cap — see 2026-04-20 tuning work below, which lifts it to 1.000). Measured via `/vectors/lance/recall/{idx}` harness.
- [x] Phase E.3: Scheduled ingest — 2026-04-17
- `ingestd::schedule` module: ScheduleDef, ScheduleStore (JSON at `_schedules/{id}.json`), Scheduler tokio task
- Supports MySQL + Postgres sources on interval triggers (Cron variant defined, parsing stubbed)
@@ -241,10 +241,68 @@
- Two-tier: lopdf text extraction → Tesseract 5.5 fallback for scanned/image PDFs
- Extracts embedded XObject /Image streams, shells to tesseract --oem 3 --psm 6
- Same schema (source_file, page_number, text_content) — downstream unchanged
- [ ] Fine-tuned domain models
- [ ] Multi-node query distribution
- [x] Catalog hygiene — idempotent `register()` + dedupe + DELETE (2026-04-19, ADR-020)
- `catalogd::Registry::register` now gates on `(name, schema_fingerprint)`: same fp → reuse `DatasetId` and update objects in place; different fp → return error (409 Conflict on HTTP, `FAILED_PRECONDITION` on gRPC). First-time registration is unchanged.
- `POST /catalog/dedupe` one-shot operator endpoint collapses pre-existing duplicates; winner = non-null `row_count` first, newest `updated_at` second.
- `DELETE /catalog/datasets/by-name/{name}` removes the manifest from both in-memory registry and object storage (metadata-only — parquet files, vector indexes, tombstones are NOT cascade-deleted). Added to support test-harness cleanup; also plugs a real catalog hole where zombie entries from prior deletes would break DataFusion schema inference.
- Cleanup run on live catalog: 374 → 31 datasets, 343 orphan manifests removed, 0 errors. 308× `successful_playbooks` was the worst offender.
- Concurrency: write lock held across storage I/O in `register()` to close the check→insert TOCTOU window (32-worker multi-threaded stress test verifies single-manifest invariant).
- End-to-end verification: `scripts/e2e_pipeline_check.sh` runs 31 assertions across 12 pipeline stages (ingest → catalog → SQL+JOIN → dedup → idempotency → metadata → PII → vector embed → semantic search → cleanup) against the live gateway. Idempotent across repeat runs.
- Tests: 11 new in `catalogd` (was 0, includes 3 concurrency tests + 3 delete_dataset tests); 11 new in `storaged` for `AppendLog` + `ErrorJournal` (was 0). Fixed a broken doctest in `append_log.rs`.
- [x] Autotune agent: portfolio rotation + auto-bootstrap (2026-04-20)
- `pick_periodic_target` now sources candidates from `IndexRegistry` (not just promoted indexes) and picks least-recently-tuned, so trial budget spreads across every index with ≥1000 vectors instead of fixating on one converged champion.
- `run_one_cycle` bootstraps on first visit: `ensure_auto_harness` auto-generates `{index}_auto` (20 synthetic self-queries, k=10, brute-force ground truth) if missing, then seeds with `HnswConfig::default()` (ec=80/es=30).
- Regression fix: `harness::recall_at_k` now uses set-intersection semantics. The prior impl counted duplicates in `predicted` — on corpora with repeated chunks (`kb_response_cache_agent`) this inflated recall above 1.0 and poisoned promotion decisions. +7 unit tests.
- [x] Scheduled ingest: real cron parsing (2026-04-20)
- Vixie-compatible 5/6-field cron via `croner` crate. Day-of-week follows Unix convention (`1-5` = Mon-Fri). 6-field adds seconds granularity.
- `validate_trigger` in `ingestd::schedule` — create/patch handlers reject malformed expressions with `400 BAD_REQUEST` at creation time, not silently at fire time.
- Swapped away from the `cron` crate (0.16) which uses a non-Unix DOW convention (`1=Sun`) that would silently bite anyone writing `1-5` expecting weekdays. +9 unit tests.
- [x] EvalSets federation (2026-04-20)
- `harness::HarnessStore` mirrors the TrialJournal / PromotionRegistry federation pattern: eval artifacts colocate with each index's recorded bucket; legacy evals in primary remain discoverable via a fallback path; cross-bucket listing dedupes.
- Every eval callsite (service.rs × 5, agent.rs × 3, autotune.rs × 1) now routes through `HarnessStore`. `VectorState` and `AgentDeps` each hold a shared instance.
- [x] Index bucket-migrate PATCH (2026-04-20)
- `PATCH /vectors/indexes/{name}/bucket` copies an index's vector parquet + trial-journal batches + promotion file + auto-harness to `dest_bucket`, flips `IndexMeta.bucket` as the commit point, and evicts the `EmbeddingCache` so next load reads from the new bucket. Optional `delete_source: true` sweeps source artifacts.
- Lance-backed indexes refused with 400 — Lance URIs are bucket-specific and require rewriting the dataset, separate story. Round-trip verified: 390 artifacts, 0.04s.
- [x] IVF_PQ recall tuning (2026-04-20)
- `LanceVectorStore::search` now accepts optional `nprobes` + `refine_factor`. Lance's built-in `nprobes=1` default was the hidden cap on recall — on 316-partition `resumes_100k_v2` it searched only 0.3% of partitions per query.
- Server defaults (`LANCE_DEFAULT_NPROBES=20`, `LANCE_DEFAULT_REFINE_FACTOR=5`) flow through the scoped-search path and the autotune harness. Measured on `resumes_100k_v2`: recall `0.805 → 1.000` at p50 ≈ 7.4ms. Even `nprobes=5, refine=5` saturates recall at p50 ≈ 4.7ms.
- `/vectors/lance/recall/{idx}` accepts per-request `nprobes` / `refine_factor` so operators can sweep the curve.
- [x] **Phase 19: Playbook memory (meta-index)** — the feedback loop originally implied by the PRD but never built. Playbooks stop being write-only; they start shaping future rankings. (2026-04-20)
- [x] 19.1 — `POST /vectors/playbook_memory/rebuild` scans `successful_playbooks` via DataFusion, builds one `PlaybookEntry` per row (operation + approach + context embedded as one vector via nomic-embed-text)
- [x] 19.2 — Brute-force cosine search over in-memory embeddings (chosen over HNSW: successful_playbooks maxes around thousands of rows, overhead of a second indexed surface isn't worth it until that ceiling bites)
- [x] 19.3 — Endorsed names parsed out of `result` column, keyed by `(city, state, name)` tuple so shared names across cities don't cross-pollinate. Parsing via `parse_names` + `parse_city_state` helpers (7 unit tests)
- [x] 19.4 — `/vectors/hybrid?use_playbook_memory=true`: fetches `top_k * 5` candidates so endorsed workers outside the vanilla top-K can still climb. Boost is additive on vector score, each hit carries `playbook_boost` + `playbook_citations` in the response for explainability
- [x] 19.5 — Multi-agent orchestrator (`tests/multi-agent/orchestrator.ts`) auto-seeds `POST /vectors/playbook_memory/seed` on consensus_done, so the next query sees the new endorsement without a full `/rebuild`. Closes the feedback loop: two agents reach consensus → playbook sealed → next query re-ranks
- [x] 19.6 — `MAX_BOOST_PER_WORKER = 0.25` enforced in `compute_boost_for`; verified with unit test (100 identical playbooks → boost capped at 0.25) and live test (5 identical seeds → exactly 0.25). Time decay deferred as optional
- Real finding surfaced during build: the 32 bootstrap rows in `successful_playbooks` reference phantom worker names — 80 of 82 don't correspond to actual rows in `workers_500k`. `/seed` endpoint bypasses `successful_playbooks` so operators can prime memory with real fixtures; production path is the orchestrator write-through
- [x] **Phase 19 refinement — geo + role prefilter on boost** (2026-04-21)
- Added `compute_boost_for_filtered` and `compute_boost_for_filtered_with_role` to `playbook_memory.rs`. SQL filter's `(city, state, role)` parsed in `service.rs`; exact role-matches in target geo skip cosine and earn similarity=1.0. Restored the feedback loop: matched=0 → matched=11 per query on the same Nashville test. Citation density on Riverfront Steel: 2 → 28 per run (14×).
- Rust unit tests: `extractor_tests::extract_target_geo_basic/_missing_state/_word_boundary`, `extract_target_role_basic/_none/_multi_word`. 6/6 pass.
- Diagnostic log: `playbook_boost: boosts=N sources=N parsed=N matched=N target_geo=? target_role=?` on every call.
- [x] **Phase 20: Model Matrix + Overseer Tiers** (2026-04-21)
- `config/models.json` — 5 tiers (t1_hot / t2_review / t3_overview / t4_strategic / t5_gatekeeper), each with context_window + context_budget + overflow_policy. Ollama Cloud bearer key from `/root/llm_team_config.json`.
- Hot path: qwen3.5:latest + qwen3:latest local with `think:false`. Mistral dropped after 0/14 fill on complex scenarios.
- T3 cloud: gpt-oss:120b via Ollama Cloud — verified 4-8s latency, strict JSON-shape output for remediation.
- [x] **Phase 21: Scratchpad + Tree-Split Continuation** (2026-04-21)
- `tests/multi-agent/agent.ts`: `estimateTokens()`, `assertContextBudget()`, `generateContinuable()`, `generateTreeSplit()`. `think` flag plumbed through sidecar's `/generate`. Empty-response backoff + truncation-continuation, no max_tokens tourniquet.
- Rust port queued: `crates/aibridge/src/continuation.rs`, `tree_split.rs`.
- [x] **Phase 22: Internal Knowledge Library** (2026-04-21)
- `data/_kb/` — signatures.jsonl, outcomes.jsonl, pathway_recommendations.jsonl, error_corrections.jsonl, config_snapshots.jsonl. Event-driven cycle: indexRun → recommendFor → loadRecommendation.
- Item B cloud rescue: failed event → cloud remediation JSON → retry with pivot. Verified 1/3 rescues succeeded on stress_01 (Gary IN → South Bend IN pivot).
- `scripts/kb_measure.py` aggregator. Unit tests: `kb.test.ts` — 4/4 pass (signature stability, role/city/count invariants, digest shape).
- [x] **Phase 23: Staffer identity + competence-weighted retrieval** (2026-04-21)
- ScenarioSpec gained `contract: ContractTerms` and `staffer: Staffer { id, name, tenure_months, role, tool_level }`.
- tool_level runtime overrides: full / local / basic / minimal. Basic + minimal route executor to Ollama Cloud `kimi-k2.5` (kimi-k2.6 pending pro-tier upgrade).
- `data/_kb/staffers.jsonl` — competence_score = 0.45·fill + 0.20·turn_eff + 0.20·cite + 0.15·rescue. Recomputed per run.
- `findNeighbors` now returns `weighted_score = cosine × max_staffer_competence`. `scripts/kb_staffer_report.py` — leaderboard + cross-staffer worker overlap (Rachel D. Lewis 12× across 4 staffers → auto-discovered high-value label).
- `gen_staffer_demo.ts` + `run_staffer_demo.sh` — 4 personas × 3 contracts = 12 runs.
- [ ] **Phase 24: Observer / Autotune integration** (GAP, not wired)
- `lakehouse-observer.service` watches MCP :3700; scenario.ts hits gateway :3100 directly. Observer idle at 0 ops across 3600+ cycles. Autotune runs on its own schedule, never sees scenario outcomes.
- Next-sprint: scenario emits per-event outcome summaries to observer's ingest path; observer ERROR_ANALYZER + PLAYBOOK_BUILDER loops consume them; autotune subscribes to the metric stream.
- [ ] Fine-tuned domain models (Phase 25+)
- [ ] Multi-node query distribution (only if ceilings bite)
---
**52+ unit tests | 13 crates | 19 ADRs | 2.47M rows | 100K vectors | Hybrid Parquet+HNSW ⊕ Lance**
**Latest: 2026-04-17 — 8 commits shipping Phase 16.2 through Phase 18**
**102 unit tests | 13 crates | 20 ADRs | 2.47M rows | 100K vectors | Hybrid Parquet+HNSW ⊕ Lance | Phase 19 refined + 20-23 shipped**
**Latest: 2026-04-21 — Phases 20-23 shipped. Geo+role prefilter lifted playbook citation density 14×. Cloud rescue converts zero-supply failures into successful pivots. Staffer competence weighting differentiates full-tool senior from minimal-tool trainee by 46.4pt fill rate on same contracts. Phase 24 observer integration flagged as honest gap.**
+137 -19
View File
@@ -380,38 +380,156 @@ Make successful playbooks actually improve future searches. Today `successful_pl
- Hard guarantees about recall lift magnitude. "Measurably better on the demo query" is the gate, not a universal quality claim.
- Real-time recomputation on every playbook. Batched refresh via the existing stale-marking path is sufficient.
### Phase 19 refinement (WIRED 2026-04-21): geo-filter + role prefilter on boost
Item-3 diagnostic pass surfaced that `compute_boost_for` was ranking playbooks globally by cosine similarity, while candidates came from an SQL-filtered city. Result: boost map had 170 endorsed workers, 0 intersected the 50 Nashville-filtered candidates. Zero citations where there should have been dozens.
Fix — in `crates/vectord/src/playbook_memory.rs`:
- `compute_boost_for_filtered(target_geo)` — skip playbooks from other cities before cosine sort.
- `compute_boost_for_filtered_with_role(target_geo, target_role)` — multi-strategy: exact (role, city, state) match earns similarity=1.0 and fills up to half the top_k; cosine fallback fills the rest. Mirrors Mem0/Zep 2026 guidance on parallel-strategy rerank.
In `crates/vectord/src/service.rs`:
- `extract_target_geo` and `extract_target_role` pull both from the executor's SQL filter.
- `tracing::info!` emits `playbook_boost: boosts=N sources=N parsed=N matched=N target_geo=? target_role=?` on every hybrid_search. Silent-truncation class of bug now visible.
Citation lift measured: avg citations per run 0.32 → 1.38 after geo filter; then 2 → 28 in the single-scenario Riverfront Steel re-run after role prefilter landed. 14× delta on same scenario.
Unit tests: `extract_target_geo_basic`, `_missing_state_returns_none`, `_word_boundary` (rejects "civilian" substring), `extract_target_role_basic`, `_none_when_absent`, `_multi_word` — all pass (`cargo test -p vectord --lib extractor_tests`).
### Phase 20: Model Matrix + Overseer Tiers (WIRED 2026-04-21)
Five-tier routing declared in `config/models.json`. Hot path (T1/T2) stays on local mistral + qwen2.5. Cloud is consulted sparingly for overview (T3 gpt-oss:120b), strategic (T4 qwen3.5:397b), and gatekeeper decisions (T5 kimi-k2-thinking). Every tier declares `context_window` + `context_budget` + `overflow_policy`. See ADR-021 (to add).
Five-tier routing declared in `config/models.json`. Hot path (T1/T2) stays local (qwen3.5 + qwen3 after mistral was dropped for 0/14 fill rate on complex scenarios). Cloud for overview (T3 gpt-oss:120b), strategic (T4 qwen3.5:397b), and gatekeeper (T5 kimi-k2-thinking). Every tier declares `context_window` + `context_budget` + `overflow_policy`.
- T1 hot: 50-200 calls/scenario, local only
- T2 review: 5-14 calls/event, local only
- T3 overview: 1-3 calls/scenario, cloud primary
- T1 hot: 50-200 calls/scenario, local only — `qwen3.5:latest` executor, `think:false`
- T2 review: 5-14 calls/event, local only — `qwen3:latest` reviewer, `think:false`
- T3 overview: 1-3 calls/scenario, cloud primary — `gpt-oss:120b` on Ollama Cloud, thinking on
- T4 strategic: 1-10 calls/day, cloud primary
- T5 gatekeeper: 1-5 calls/day, audit-logged
T3 checkpoints + cross-day lessons are wired. Lessons archive to `data/_playbook_lessons/` and load back at next scenario start as `prior_lessons` in executor context.
T3 checkpoints + cross-day lessons wired. Lessons archive to `data/_playbook_lessons/` and load back at next scenario start as `prior_lessons` in executor context. Cloud passthrough verified on stress_01 scenario with `LH_OVERVIEW_CLOUD=1` — `gpt-oss:120b` response latency consistently 4-8s, diagnosing city-pivot ("Gary IN → Chicago IL, 40mi") when target city has zero supply.
### Phase 21: Context Stability & Chunking Pipeline
`think:false` is the key mechanical finding — qwen3.5 burns ~650 tokens of hidden reasoning before emitting response; hot-path JSON emitters MUST disable thinking or continuation has to paper over empty returns. T3/T4 overseers KEEP thinking (that's the point).
**Why this is a phase and not an optimization:** when playbooks accumulate into the thousands, naive concatenation blows any model's context window. LLM Team hit this exact issue running multi-model ranking — context got lost silently, rankings degraded, and there was no pipeline to catch it. We cannot trust cloud inference to save us: bigger context window just raises the cliff. The stable answer is a chunking + caching layer that runs BEFORE any agent call.
**Kimi-k2.6 upgrade path:** Current Ollama Cloud key returns 403 on kimi-k2.6 (`ollama run kimi-k2.6:cloud` requires `ollama signin` with pro-tier account). kimi-k2.5 substitutes on the current tier — same family, strong at tool calling. Swap to k2.6 is a one-line change in `applyToolLevel` once the subscription lands.
### Phase 21: Scratchpad + Tree-Split Continuation
**Why this is a phase and not an optimization:** bumping `max_tokens` until a response stops truncating is a tourniquet — J called this out explicitly. As playbooks accumulate into the hundreds and responses grow, eventually SOME request will exceed SOME model's window, and we can't solve it by raising a number. The stable answer is two primitives that let us handle arbitrary-size work without losing context: a scratchpad that glues multi-call responses together, and a tree split that shards oversized inputs and reduces them back.
**Two primitives (WIRED 2026-04-21 in `tests/multi-agent/agent.ts`):**
1. **`generateContinuable()`** — handles OUTPUT overflow. Calls the model; checks structural completeness (for JSON: matched braces + JSON.parse success; for text: non-empty). If incomplete, calls again with "continue from here" + the partial response as scratchpad. Up to `max_continuations` times. No `max_tokens` tuning needed — if thinking ate the initial budget, continuation picks up the slack.
2. **`generateTreeSplit()`** — handles INPUT overflow. Caller passes an array of shards (semantic chunks of the corpus). For each shard: map call with running scratchpad digest. Final reduce call produces the answer. Scratchpad truncates oldest content if it approaches its own budget. If a single shard still overflows, `assertContextBudget` throws — caller must re-shard at finer granularity, NOT silently truncate.
**Guarantees:**
1. Every `generate()` call goes through a budget check against the model's declared `context_window` minus `safety_margin`.
2. On overflow, the declared `overflow_policy` fires — one of: summarize oldest tool results via T3, cosine-rank top-K lessons, two-pass map-reduce across shards, or escalate to a bigger-context model.
3. Chunk shards of large corpora (playbook_memory, lesson archive) are precomputed into `data/_chunk_cache/` keyed by corpus hash. Invalidation is automatic when the hash changes.
4. Token estimation uses `chars / 4` (biased safe by ~15%) until we wire the provider's tokenizer.
1. No agent call can silently truncate. Either it completes, continues, or throws with numbers.
2. No corpus is too big — `generateTreeSplit` handles any size the caller can shard.
3. Scratchpad is the glue between multi-call responses; context is never lost, only compacted.
4. Token estimation uses `chars / 4` (biased safe ~15%) until we wire the provider's tokenizer.
**What lives where:**
- Budget checker in `crates/aibridge/src/budget.rs` (new)
- Chunker in `crates/aibridge/src/chunk.rs` (new) — deterministic splits on sentence/paragraph boundaries, each shard ≤ policy cap
- Cache service in `crates/storaged/src/chunk_cache.rs` (new) — `corpus_hash → [shard_id, tokens, content]`
- Agent helper `tests/multi-agent/agent.ts::generateSafe()` wraps `generate()` + `generateCloud()` with the checker
- Metric emitted on every overflow: `context_overflow{tier=?,policy=?,model=?}` → surfaces on /metrics
**What lives where now:**
- `agent.ts::estimateTokens()` + `assertContextBudget()` + `generateContinuable()` + `generateTreeSplit()` — WIRED
- `scenario.ts` executor + reviewer + overviewGenerate calls — migrated to `generateContinuable`
- `config/models.json` — context_window + context_budget + overflow_policies per tier (declarative)
**Status:** `context_window` + `context_budget` + `overflow_policies` WIRED in config/models.json. Enforcement helpers NOT yet wired. Implementation target: current sprint — this is a stability floor, not a feature.
**Next sprint (Rust side, so gateway tools share it):**
- `crates/aibridge/src/continuation.rs` — port of `generateContinuable`
- `crates/aibridge/src/tree_split.rs` — port of `generateTreeSplit`
- `crates/storaged/src/chunk_cache.rs` — precomputed shards keyed by corpus hash (avoid re-chunking on every T4 run)
- `/metrics` counter: `context_continuations_total{model,shape,succeeded}`
### Phase 22+: Further horizon
**Status:** TS primitives WIRED. Rust port pending. The escalation path (tree split → bigger-context cloud model → kimi-k2:1t's 1M window → split decision into sub-decisions) is declared in `config/models.json` under `context_management.overflow_policies`.
### Phase 21 status update (WIRED 2026-04-21 evening)
Additional primitives landed after the initial commit:
- **`think: boolean`** flag plumbed through `generate()`, `generateCloud()`, `generateContinuable()`, and into sidecar's `/generate` endpoint. Enables per-call opt-out of hidden reasoning for hot-path JSON emitters. Verified: qwen3.5 with `think:false` + `num_predict:400` returns clean `{"worker_id":...}` on first call; without `think:false`, 650 tokens eaten by reasoning, response empty.
- **Cloud executor routing** — `ACTIVE_EXECUTOR_CLOUD` / `ACTIVE_REVIEWER_CLOUD` flags let per-staffer tool_level route executor to Ollama Cloud when weak local model (qwen2.5) would collapse. Verified on kimi-k2.5 via Ollama Cloud: clean JSON emission, think:false honored.
Rust port of continuation + tree-split primitives remains queued for next sprint (`crates/aibridge/src/continuation.rs`, `tree_split.rs`).
### Phase 22: Internal Knowledge Library (KB)
Meta-layer over Phase 19 playbook_memory. Playbook memory answers "which WORKERS worked for this event." The KB answers "which CONFIG worked for this playbook signature." Subject changes from workers to the system itself — model choice, budget hints, overflow policies, pathway notes.
**Files (`data/_kb/`):**
- `signatures.jsonl` — (sig_hash, embedding[], first_seen, last_seen, run_count). Sig = stable hash of the sequence of (kind, role, count, city, state) across events.
- `outcomes.jsonl` — per-run record: {sig, run_id, models, ok/total, turns, citations, per-event summary, elapsed}.
- `pathway_recommendations.jsonl` — AI-synthesized for next run: {confidence, rationale, top_models, budget_hints, pathway_notes, neighbors_consulted}.
- `error_corrections.jsonl` — detected fail→succeed pairs on same sig, diff of what changed.
- `config_snapshots.jsonl` — history of models.json changes + why.
**Cycle (event-driven, not wall-clock):**
1. Scenario ends → `kb.indexRun()` extracts signature, embeds spec digest, appends outcome.
2. `kb.recommendFor(nextSpec)` finds k-NN signatures via cosine, feeds their outcome history + recent error corrections to the overview model, writes a structured recommendation.
3. Next scenario starts → `kb.loadRecommendation(spec)` pulls the newest rec for this sig, injects `pathway_notes` into `guidanceFor()` alongside prior lessons.
**Why file-based for MVP:** Phase 19 playbook_memory is already a catalogd dataset. KB is a separate meta-layer; keep it file-based first to iterate without a gateway schema migration. Rust port (and promotion to vectord-indexed corpus for neighbor search at scale) lands once shape stabilizes — mirrors how Phase 21 primitives were TS-first → Rust next sprint.
**What the overview model gets asked:**
- Target scenario digest
- Top-k neighbor signatures with avg ok rate, best model combo per neighbor
- Recent error corrections (sig, before/after model set)
**What it outputs (JSON-constrained):**
- confidence (high/medium/low)
- rationale (2-3 sentences)
- top_models {executor, reviewer, overview}
- budget_hints {executor_max_tokens, reviewer_max_tokens, executor_think}
- pathway_notes (concrete pre-run advice)
**Status (WIRED 2026-04-21):** `tests/multi-agent/kb.ts` holds all primitives. scenario.ts reads rec at start, indexes + recommends at end. Cold start gracefully writes a "low confidence, no history" rec so the second run has a floor to build on. `snapshotConfig()` wired to fire at every scenario start — active model set + tool_level + cloud flags hashed and appended to `config_snapshots.jsonl`.
**Phase 22 item B — cloud rescue (WIRED):** When an event fails and cloud T3 is enabled, `requestCloudRemediation()` feeds the failure trace (SQL filters attempted, row counts, reviewer drift reasons, gap signals, contract terms) to cloud and parses a JSON remediation with new_city / new_state / new_role / new_count / rationale. Event retries once with the pivot. Verified 1/3 rescues succeeded on stress_01 (Gary IN → South Bend IN pivot filled a Welder that local drift-aborted). Sanitizer splits "City, ST" comma-packed outputs so downstream SQL doesn't get `Hammond, IN, IN`.
### Phase 23: Staffer identity + competence-weighted retrieval (WIRED 2026-04-21)
Answers "who handled this" as a first-class dimension of the matrix index. Senior staffers' playbooks rank higher than juniors' on similar scenarios via competence × similarity score. Auto-discovers "reliable performer" worker labels via cross-staffer endorsement overlap.
**Schema (`scenario.ts` ScenarioSpec):**
- `contract?: ContractTerms` — deadline, budget_per_hour_max, local_bonus_per_hour, local_bonus_radius_mi, fill_requirement. Propagates into T3 checkpoint + cloud rescue prompts so cloud reasons about trade-offs (pivot-within-radius before budget-pivot-further).
- `staffer?: Staffer` — {id, name, tenure_months, role, tool_level}. tool_level controls subsystems available to this run:
- `full` — qwen3.5 + qwen3 local + cloud T3 + cloud rescue
- `local` — qwen3.5 + qwen3 local + local gpt-oss:20b T3 + rescue
- `basic` — **kimi-k2.5 cloud** exec + qwen3 local reviewer + local T3, no rescue
- `minimal` — kimi-k2.5 cloud exec + qwen3 local reviewer, NO T3, NO rescue — tests whether playbook inheritance carries knowledge alone
**KB staffer indexing (`data/_kb/staffers.jsonl`):**
- Recomputed per-staffer on every run: total_runs, fill_rate, avg_turns_per_event, avg_citations_per_run, rescue_rate, competence_score.
- `competence_score = 0.45·fill_rate + 0.20·turn_efficiency + 0.20·citation_density + 0.15·rescue_rate`. Bounded 0..1.
**Weighted neighbor retrieval:**
- `findNeighbors` in `kb.ts` returns `weighted_score = cosine × max_staffer_competence` (floor 0.3). Senior playbooks rank above junior playbooks on similar scenarios.
- `pathway_recommendations` include `best_staffer_id` / `best_staffer_competence` so cloud knows WHOSE playbook it's synthesizing from.
**Cross-staffer auto-discovery:**
- `scripts/kb_staffer_report.py` emits leaderboard + workers endorsed across ≥2 staffers on same signature.
- Validated output: Rachel D. Lewis (Welder Nashville) endorsed 12× across 4 staffers; Christina Watson (Machine Op Indianapolis) 11×. These are the highest-confidence "reliable performer" labels the system produced without human tagging.
**Demo infrastructure:**
- `tests/multi-agent/gen_staffer_demo.ts` — 4 personas × 3 contracts = 12 scenario specs.
- `scripts/run_staffer_demo.sh` — sequential batch with cloud T3.
- `scripts/kb_staffer_report.py` — leaderboard + top/bottom differential + cross-staffer overlap.
### Phase 24: Observer / Autotune integration (NOT YET WIRED — honest gap)
J flagged this 2026-04-21 evening: the `lakehouse-observer.service` systemd unit has been running for 3600+ cycles but shows `total_ops=0 successes=0 failures=0` because `tests/multi-agent/scenario.ts` hits the Rust gateway directly on port 3100, bypassing the Bun MCP layer on 3700 that observer wraps.
Result: our test scenarios are INVISIBLE to the observer and the autotune pipeline. Autotune's HNSW parameter learning runs on its own schedule, but no signal from scenario outcomes flows into it.
**Target architecture:**
- Scenarios emit per-event outcome summaries to a path the observer polls (or POST to observer's ingest endpoint directly).
- Observer's ERROR ANALYZER + PLAYBOOK BUILDER loops consume those summaries alongside the MCP-layer ops.
- Autotune agent subscribes to a metric stream the observer writes.
**Why deferred:** this is a real architecture change (coherent data path from scenario → observer → autotune → vectord index) and needs care. The observer's current `observed_operations` ingest uses REPLACE semantics (flagged in `feedback_ingest_replace_semantics.md`) — naive appending will wipe prior ops.
**Status:** GAP DOCUMENTED, not fixed. Scenarios continue to populate KB directly. The parallel pipelines are coherent but separate; Phase 24 connects them.
### Phase 25+: Further horizon
- Specialized fine-tuned models per domain (staffing matcher, resume parser)
- Video/audio transcript ingest + multimodal embeddings
+20
View File
@@ -789,6 +789,26 @@ async function main() {
// workers_500k manifest is correct (500K); candidates manifest
// lied (said 100K, actual 1K) — the audit caught it.
// Everything else uses manifest row_count since it's O(1).
// Phase 24 refinement — unified memory query endpoint. Accepts
// any input (natural language, structured JSON, mixed) via
// POST body {input: <anything>}. Normalizer handles the shape.
// Returns a single bundle with every memory surface relevant:
// playbook workers, KB recommendation, neighbor signatures,
// prior lessons, top staffers, discovered patterns.
if (url.pathname === "/memory/query" && req.method === "POST") {
try {
const body: any = await req.json();
const { queryMemory } = await import("../tests/multi-agent/memory_query.ts");
const result = await queryMemory(body.input ?? body);
return ok(result);
} catch (e) {
return new Response(JSON.stringify({ error: (e as Error).message }), {
status: 500,
headers: { "content-type": "application/json" },
});
}
}
if (url.pathname === "/system/summary") {
const [ds, indexes, workersCount, candsCount] = await Promise.all([
api("GET", "/catalog/datasets").catch(() => [] as any),
+108 -8
View File
@@ -20,6 +20,9 @@
const GATEWAY = process.env.GATEWAY_URL || "http://localhost:3700";
const LAKEHOUSE = process.env.LAKEHOUSE_URL || "http://localhost:3100";
const CYCLE_SECS = parseInt(process.env.OBSERVER_CYCLE || "30");
// Phase 24 — observer now listens on an HTTP port for external ops
// (scenarios bypass the MCP:3700 layer by design). Default 3800.
const OBSERVER_PORT = parseInt(process.env.OBSERVER_PORT || "3800");
// ─── Observed operation log ───
@@ -31,10 +34,32 @@ interface ObservedOp {
duration_ms: number;
output_summary: string;
error?: string;
// Phase 24 — optional provenance so error analyzer and playbook
// builder can differentiate MCP-layer ops from scenario-sourced
// events. Scenarios set source="scenario" + staffer_id + sig_hash.
source?: "mcp" | "scenario";
staffer_id?: string;
sig_hash?: string;
event_kind?: string;
role?: string;
city?: string;
state?: string;
count?: number;
rescue_attempted?: boolean;
rescue_succeeded?: boolean;
}
const recentOps: ObservedOp[] = [];
// Phase 24 — external ingest path. Scenarios POST outcome summaries
// here so the observer's analyzer + playbook builder see them. Called
// from the Bun.serve() handler below. Same ring buffer as the MCP-
// wrapped path so downstream loops don't need to know the source.
export function recordExternalOp(op: ObservedOp): void {
recentOps.push({ ...op, source: op.source ?? "scenario" });
if (recentOps.length > 2000) recentOps.shift();
}
// ─── Wrapped gateway caller — every call gets observed ───
export async function observed(
@@ -93,16 +118,22 @@ function summarize(data: any): string {
return JSON.stringify(data).slice(0, 100);
}
// Phase 24 honesty fix — the old persistOp used /ingest/file which
// REPLACES the dataset (flagged in feedback_ingest_replace_semantics.md).
// Every op silently wiped all prior ops. Now we append a JSONL line to
// data/_observer/ops.jsonl so the historical trace is durable. The
// observer analyzer + playbook builder read from this file when it
// outgrows the 2000-entry in-memory ring.
async function persistOp(op: ObservedOp) {
const csv = `timestamp,endpoint,input_summary,success,duration_ms,output_summary,error
"${op.timestamp}","${op.endpoint}","${esc(op.input_summary)}",${op.success},${op.duration_ms},"${esc(op.output_summary)}","${esc(op.error || "")}"`;
const form = new FormData();
form.append("file", new Blob([csv], { type: "text/csv" }), "op.csv");
await fetch(`${LAKEHOUSE}/ingest/file?name=observed_operations`, { method: "POST", body: form }).catch(() => {});
try {
const { mkdir, appendFile } = await import("node:fs/promises");
await mkdir("data/_observer", { recursive: true });
await appendFile("data/_observer/ops.jsonl", JSON.stringify(op) + "\n");
} catch {
// Persistence is best-effort; in-memory ring still works.
}
}
function esc(s: string) { return s.replace(/"/g, '""').replace(/\n/g, ' '); }
// ─── Error analyzer loop ───
@@ -189,10 +220,74 @@ async function consolidatePlaybooks() {
}
}
// ─── HTTP listener for external ops (Phase 24) ───
// Scenarios POST per-event outcomes here so the observer's analyzer +
// playbook builder see them alongside MCP-wrapped ops. Read-only stats
// also exposed at /stats for external health checks.
function startHttpListener() {
Bun.serve({
port: OBSERVER_PORT,
hostname: "0.0.0.0",
fetch(req) {
const url = new URL(req.url);
if (req.method === "GET" && url.pathname === "/health") {
return new Response(JSON.stringify({ status: "ok", ops_in_ring: recentOps.length }));
}
if (req.method === "GET" && url.pathname === "/stats") {
const bySource = new Map<string, number>();
for (const o of recentOps) {
const k = o.source ?? "mcp";
bySource.set(k, (bySource.get(k) ?? 0) + 1);
}
return new Response(JSON.stringify({
total: recentOps.length,
successes: recentOps.filter(o => o.success).length,
failures: recentOps.filter(o => !o.success).length,
by_source: Object.fromEntries(bySource),
recent_scenario_ops: recentOps
.filter(o => o.source === "scenario")
.slice(-10)
.map(o => ({ ts: o.timestamp, ok: o.success, staffer: o.staffer_id, kind: o.event_kind, role: o.role })),
}));
}
if (req.method === "POST" && url.pathname === "/event") {
return req.json().then((body: any) => {
const op: ObservedOp = {
timestamp: body.timestamp ?? new Date().toISOString(),
endpoint: body.endpoint ?? "scenario:fill",
input_summary: body.input_summary ?? `${body.event_kind ?? "?"} ${body.role ?? "?"}×${body.count ?? "?"} in ${body.city ?? "?"}, ${body.state ?? "?"}`,
success: !!body.success,
duration_ms: Number(body.duration_ms ?? 0),
output_summary: body.output_summary ?? (body.success ? "filled" : (body.error ?? "failed")),
error: body.error,
source: "scenario",
staffer_id: body.staffer_id,
sig_hash: body.sig_hash,
event_kind: body.event_kind,
role: body.role,
city: body.city,
state: body.state,
count: body.count,
rescue_attempted: !!body.rescue_attempted,
rescue_succeeded: !!body.rescue_succeeded,
};
recordExternalOp(op);
persistOp(op).catch(() => {});
return new Response(JSON.stringify({ accepted: true, ring_size: recentOps.length }));
}).catch((e: Error) =>
new Response(JSON.stringify({ error: e.message }), { status: 400 }));
}
return new Response("not found", { status: 404 });
},
});
console.error(`[observer] HTTP listener bound to 0.0.0.0:${OBSERVER_PORT}`);
}
// ─── Main loop ───
async function main() {
console.error(`[observer] started — cycle=${CYCLE_SECS}s, gateway=${GATEWAY}`);
console.error(`[observer] started — cycle=${CYCLE_SECS}s, gateway=${GATEWAY}, port=${OBSERVER_PORT}`);
// Run a health check first
const health = await fetch(`${GATEWAY}/health`).then(r => r.json()).catch(() => null);
@@ -202,6 +297,9 @@ async function main() {
}
console.error(`[observer] gateway healthy: ${JSON.stringify(health)}`);
// Phase 24 — bind HTTP listener so scenarios can POST outcomes.
startHttpListener();
// Main loop
let cycle = 0;
while (true) {
@@ -216,11 +314,13 @@ async function main() {
await consolidatePlaybooks();
}
const scenarioOps = recentOps.filter(o => o.source === "scenario").length;
const stats = {
cycle,
total_ops: recentOps.length,
successes: recentOps.filter(o => o.success).length,
failures: recentOps.filter(o => !o.success).length,
scenario_ops: scenarioOps,
};
console.error(`[observer] cycle ${cycle}: ${JSON.stringify(stats)}`);
}
+100 -16
View File
@@ -123,10 +123,11 @@ table.plain tr:hover td{background:#0d1117}
<tr><td class="mono">crates/aibridge/</td><td>Rust ↔ Python sidecar. HTTP client over FastAPI wrapper around Ollama. VRAM introspection via nvidia-smi. All LLM calls (embed, generate, rerank) flow through here.</td></tr>
<tr><td class="mono">crates/gateway/</td><td>Axum HTTP (:3100) + gRPC (:3101). Auth middleware, tools registry (Phase 12 — governed actions), CORS. Every external request enters here.</td></tr>
<tr><td class="mono">crates/ui/</td><td>Dioxus WASM developer UI. Internal tool. Not exposed externally.</td></tr>
<tr><td class="mono">mcp-server/</td><td>Bun/TypeScript recruiter-facing app. Serves <code>devop.live/lakehouse</code>. Routes: <code>/search /match /log /log_failure /clients/:c/blacklist /intelligence/*</code>. Proxies to the Rust gateway for heavy work.</td></tr>
<tr><td class="mono">tests/multi-agent/</td><td>Dual-agent scenario harness. <code>agent.ts</code> (prompts + protocol), <code>orchestrator.ts</code> (single task), <code>scenario.ts</code> (5-event warehouse week), <code>run_e2e_rated.ts</code> (parallel pairs + rating), <code>chain_of_custody.ts</code> (layer-by-layer audit).</td></tr>
<tr><td class="mono">mcp-server/</td><td>Bun/TypeScript recruiter-facing app. Serves <code>devop.live/lakehouse</code>. Routes: <code>/search /match /log /log_failure /clients/:c/blacklist /intelligence/* /memory/query /models/matrix /system/summary</code>. Observer sibling at <code>observer.ts</code> with HTTP listener on :3800 for scenario event ingest. Proxies to the Rust gateway for heavy work.</td></tr>
<tr><td class="mono">tests/multi-agent/</td><td>Dual-agent scenario harness + memory stack. <code>agent.ts</code> (prompts, continuation + tree-split primitives, cloud routing), <code>orchestrator.ts</code>, <code>scenario.ts</code> (contracts + staffer + tool_level), <code>kb.ts</code> (KB indexing, competence scoring, neighbor retrieval), <code>normalize.ts</code> (input normalizer — structured / regex / LLM), <code>memory_query.ts</code> (unified /memory/query), <code>gen_scenarios.ts</code> + <code>gen_staffer_demo.ts</code> (corpus generators), <code>run_e2e_rated.ts</code>, <code>chain_of_custody.ts</code>. Unit tests colocated (<code>kb.test.ts</code>, <code>normalize.test.ts</code>).</td></tr>
<tr><td class="mono">config/</td><td><code>models.json</code> — authoritative 5-tier model matrix (T1 hot local / T2 review local / T3 overview cloud / T4 strategic / T5 gatekeeper). Per-tier context_window + context_budget + overflow_policy. Read at runtime by scenario.ts; hot-swap friendly.</td></tr>
<tr><td class="mono">docs/</td><td><code>PRD.md</code>, <code>PHASES.md</code>, <code>DECISIONS.md</code> (20 ADRs). Every significant architectural choice has an ADR with the alternatives that were rejected and why.</td></tr>
<tr><td class="mono">data/</td><td>Default local object store. Parquet files per dataset, append-log batches, HNSW trial journals, promotion registries, playbook_memory state.json, catalog manifests. Rebuildable from repo + this dir alone.</td></tr>
<tr><td class="mono">data/</td><td>Default local object store. Parquet files per dataset, append-log batches, HNSW trial journals, promotion registries, <code>_playbook_memory/state.json</code> (now with retirement fields — Phase 25), catalog manifests. Plus four learning-loop directories: <code>_kb/</code> (signatures, outcomes, recommendations, error_corrections, config_snapshots, staffers), <code>_playbook_lessons/</code> (T3 cross-day lessons archived per run), <code>_observer/ops.jsonl</code> (append journal, durable scenario outcome stream), <code>_chunk_cache/</code> (spec'd for Phase 21 Rust port). Rebuildable from repo + this dir alone.</td></tr>
</tbody>
</table>
</div>
@@ -197,7 +198,33 @@ table.plain tr:hover td{background:#0d1117}
<li>Search via <code>POST /vectors/profile/&lt;id&gt;/search</code> rejects out-of-scope queries with 403 + list of allowed bindings</li>
<li>Ollama swaps to the profile's model via <code>keep_alive=0</code>; only one model in VRAM at a time</li>
</ul>
<div class="ref"><strong>Code:</strong> crates/vectord/src/{hnsw.rs, autotune.rs, agent.rs, promotion.rs} · ADR-019</div>
<h3>Model matrix (Phase 20)</h3>
<p>Five tiers declared in <code>config/models.json</code>. Each call site picks the tier appropriate to its purpose — hot-path JSON emitters get fast local, overview/strategic/gatekeeper decisions get thinking models on cloud. Every tier carries <code>context_window</code>, <code>context_budget</code>, and <code>overflow_policy</code>.</p>
<table class="plain">
<thead><tr><th>Tier</th><th>Purpose</th><th>Primary model</th><th>Frequency</th></tr></thead>
<tbody>
<tr><td>T1 hot</td><td>Per tool call — SQL gen, hybrid_search, propose_done</td><td><code>qwen3.5:latest</code> local, <code>think:false</code></td><td>50-200/scenario</td></tr>
<tr><td>T2 review</td><td>Per-step consensus, drift flagging</td><td><code>qwen3:latest</code> local, <code>think:false</code></td><td>5-14/event</td></tr>
<tr><td>T3 overview</td><td>Mid-day checkpoints + cross-day lesson distill</td><td><code>gpt-oss:120b</code> Ollama Cloud, thinking on</td><td>1-3/scenario</td></tr>
<tr><td>T4 strategic</td><td>Pattern re-ranking, weekly gap audit</td><td><code>qwen3.5:397b</code> cloud</td><td>1-10/day</td></tr>
<tr><td>T5 gatekeeper</td><td>Schema migrations, autotune config changes</td><td><code>kimi-k2-thinking</code> cloud, audit-logged</td><td>1-5/day</td></tr>
</tbody>
</table>
<p><strong>Key mechanical finding (2026-04-21):</strong> qwen3.5 and qwen3 are <em>thinking</em> models — they burn ~650 tokens of hidden reasoning before emitting the visible response. For hot-path JSON emitters this meant 400-token budgets returned empty strings. Fix: <code>think: false</code> plumbed through sidecar's <code>/generate</code> endpoint; hot path disables thinking (structure matters more than reasoning depth), overseer tiers keep it on. Mistral was dropped entirely after a 0/14 fill rate on complex scenarios (decoder-level malformed-JSON bug, not a prompt issue).</p>
<p><strong>Continuation primitive (Phase 21):</strong> <code>generateContinuable()</code> handles output-overflow without <code>max_tokens</code> tourniquets — empty response → geometric backoff retry; truncated-JSON → continue with partial as scratchpad. <code>generateTreeSplit()</code> handles input-overflow via map-reduce with running scratchpad. Both respect <code>assertContextBudget()</code> so silent truncation can't happen.</p>
<h3>Per-staffer tool_level (Phase 23)</h3>
<p>Scenarios can be scoped to a specific coordinator (<code>staffer: {id, name, tenure_months, role, tool_level}</code>). <code>tool_level</code> controls which tiers are available:</p>
<ul>
<li><code>full</code> — T1/T2 local, T3 cloud, cloud rescue on failure</li>
<li><code>local</code> — T1/T2/T3 all local (gpt-oss:20b as overseer)</li>
<li><code>basic</code> — kimi-k2.5 cloud executor + local reviewer + local T3, no rescue</li>
<li><code>minimal</code> — kimi-k2.5 cloud executor, no T3, no rescue. Playbook inheritance is the only signal.</li>
</ul>
<p>Measured 2026-04-21 on a 36-run demo (4 staffers × 3 contracts × 3 rounds): James Park (mid, local tools) ranked first at 92.9% fill and 36.8 cites/run, Maria Chen (senior, full tools) second at 81.0%. Cloud T3 adds latency without measurable benefit on this workload. Alex Rivera (trainee, minimal) still hit 59.5% fill purely from playbook inheritance — proof the memory carries knowledge when the model is capable.</p>
<div class="ref"><strong>Code:</strong> crates/vectord/src/{hnsw.rs, autotune.rs, agent.rs, promotion.rs} · tests/multi-agent/{agent.ts, scenario.ts} · config/models.json · ADR-019</div>
</div>
<!-- ═══ 4. CONTRACT INFERENCE ═══ -->
@@ -251,19 +278,59 @@ table.plain tr:hover td{background:#0d1117}
<div class="chapter" id="ch6">
<div class="num">Chapter 6</div>
<h2>How it gets better over time</h2>
<div class="lede">Compounding learning in three paths — all three happen automatically, no operator intervention required.</div>
<div class="lede">Compounding learning across seven paths. The first three are automatic background loops. Paths 4-7 landed 2026-04-21 and turn the system into a reinforcement-learning pipeline: outcomes → knowledge base → pathway recommendations → cloud rescue → competence-weighted retrieval → observer analysis. All seven happen without operator intervention.</div>
<h3>Path 1 — Playbook boost (Phase 19)</h3>
<p>Every sealed fill is seeded to <code>playbook_memory</code> via <code>/vectors/playbook_memory/seed</code>. The next hybrid query for a semantically similar role+geo surfaces the past endorsed workers with a boost. Math:</p>
<h3>Path 1 — Playbook boost with geo + role prefilter (Phase 19 + refinement)</h3>
<p>Every sealed fill is seeded to <code>playbook_memory</code>. The boost fires inside <code>/vectors/hybrid</code> when <code>use_playbook_memory: true</code>. Math, tightened 2026-04-21 after a diagnostic pass found globally-ranked playbooks were missing the SQL-filtered candidate pool entirely:</p>
<pre>per_worker = cosine(query_emb, playbook_emb) × 0.5 × e^(-age/30) × 0.5^failures / n_workers
boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
<p>Caps, decay, and negative signal mean one popular worker can't dominate, old playbooks fade, and no-shows stop boosting. Verified live: 3 identical seeds → +0.250 boost capped, 3 citations.</p>
<p><strong>Multi-strategy retrieval (new):</strong> before cosine, <code>compute_boost_for_filtered_with_role(target_geo, target_role)</code> prefilters to same-city playbooks, then gives exact (role, city, state) matches similarity=1.0 and fills up to half the top-k. Cosine fills the rest. Mirrors 2026 Mem0/Zep guidance on parallel-strategy rerank.</p>
<p><strong>Measured lift:</strong> before geo-filter, Nashville Welder query returned <code>boosts=170 matched=0</code> (zero intersection with candidate pool). After: <code>boosts=36 matched=11</code>. On the Riverfront Steel scenario, total playbook citations went from 2 → 28 per run — a 14× delta on identical inputs. The diagnostic log <code>playbook_boost: boosts=N sources=N parsed=N matched=N target_geo=? target_role=?</code> runs on every hybrid call so the class of silent-miss bug stays visible.</p>
<h3>Path 2 — Pattern discovery (meta-index)</h3>
<p><code>/vectors/playbook_memory/patterns</code> goes beyond "who was endorsed" to answer "what did past similar fills have in common?" Aggregates recurring certifications, skills, archetype, reliability distribution across the top-K semantically similar playbooks. Surfaces signal the operator didn't explicitly query for.</p>
<h3>Path 3 — Autotune agent</h3>
<p>The <code>vectord::agent</code> background task runs continuously. Watches the HNSW trial journal, proposes configs, executes trials, promotes Pareto winners — without human intervention. Operator sees "the index got faster overnight" and doesn't know why. The journal knows why.</p>
<h3>Path 4 — Knowledge Base + pathway recommender (Phase 22)</h3>
<p>Meta-layer over playbook_memory. Files under <code>data/_kb/</code>:</p>
<ul>
<li><code>signatures.jsonl</code> — sig_hash + embedding of every run's event shape</li>
<li><code>outcomes.jsonl</code> — per-run summary (models, fill rate, turns, citations, rescue stats, staffer, elapsed)</li>
<li><code>pathway_recommendations.jsonl</code> — AI-synthesized advice for the next run of a similar sig</li>
<li><code>error_corrections.jsonl</code> — fail→succeed deltas on the same sig</li>
<li><code>config_snapshots.jsonl</code> — hash of active models + tool_level at each run</li>
</ul>
<p>Cycle: scenario ends → <code>kb.indexRun()</code> appends outcome → <code>kb.recommendFor(nextSpec)</code> finds k-NN signatures, feeds outcome history to an overview model, writes structured JSON advice → next scenario reads it via <code>kb.loadRecommendation(spec)</code> and injects <code>pathway_notes</code> into the executor's context alongside prior T3 lessons.</p>
<h3>Path 5 — Cloud rescue on failure (Phase 22 item B)</h3>
<p>When an event fails (drift abort, JSON parse, pool exhaustion) and cloud T3 is enabled, <code>requestCloudRemediation()</code> feeds the full failure trace — SQL filters attempted, row counts, reviewer drift notes, gap signals, contract terms — to <code>gpt-oss:120b</code> on Ollama Cloud. Cloud returns structured <code>{retry, new_city, new_state, new_role, new_count, rationale}</code>. Event retries once with the pivot. Verified on stress_01: Gary IN (zero workers indexed) misplacement → cloud proposed South Bend IN → retry filled 1/1.</p>
<h3>Path 6 — Staffer competence-weighted retrieval (Phase 23)</h3>
<p>Answers "who handled this" as a first-class matrix-index dimension. Each scenario carries <code>staffer: {id, name, tenure_months, role, tool_level}</code>. After every run, <code>recomputeStafferStats(staffer_id)</code> aggregates their fill_rate, turn efficiency, citation density, rescue rate into a single <code>competence_score</code> (0.45·fill + 0.20·turn_eff + 0.20·cites + 0.15·rescue).</p>
<p><code>findNeighbors</code> returns <code>weighted_score = cosine × max_staffer_competence</code> — top-performer playbooks rank above juniors' on similar scenarios. Auto-discovery emerges: running 4 staffers × 3 contracts × 3 rounds surfaced Rachel D. Lewis (Welder Nashville) with 18 endorsements across all 4 staffers, Angela U. Ward (Machine Op Indianapolis) with 19 — reliable-performer labels the system built without human tagging.</p>
<h3>Path 7 — Observer outcome ingest (Phase 24)</h3>
<p>Observer runs as <code>lakehouse-observer.service</code>, now with an HTTP listener on <code>:3800</code>. Scenarios POST per-event outcomes to <code>/event</code> with full provenance (staffer_id, sig_hash, event_kind, role, city, state, rescue flags). Observer's ERROR_ANALYZER and PLAYBOOK_BUILDER loops consume them alongside MCP-wrapped ops. Persistence switched from the old <code>/ingest/file</code> REPLACE path to an append-only <code>data/_observer/ops.jsonl</code> journal so the trace survives across restarts.</p>
<h3>Input normalizer + unified memory query</h3>
<p>Two surfaces added 2026-04-21 to make the memory stack respond coherently to any input shape:</p>
<ul>
<li><code>normalizeInput(raw)</code> — accepts structured JSON, natural language, or mixed. Three-tier: structured fast-path → regex (handles "need 3 welders in Nashville, TN" in 0ms without an LLM call) → qwen3 LLM fallback for low-signal inputs.</li>
<li><code>POST /memory/query</code> — one endpoint, returns every memory surface in a single bundle: <code>playbook_workers</code> (with boost + citations), <code>pathway_recommendation</code> (KB), <code>neighbor_signatures</code> (competence-weighted), <code>prior_lessons</code> (T3 overseer history), <code>top_staffers</code> (leaderboard), <code>discovered_patterns</code> (auto-surfaced reliable workers for this role+city), <code>latency_ms</code> (per-source). Natural-language query end-to-end: 319ms.</li>
</ul>
<h3>Honest gaps — what we can still implement</h3>
<p>Three of the five 2026-era memory findings remain unwired. Flagged for near-term implementation, not hidden:</p>
<ul>
<li><strong>Zep-style validity windows</strong> — playbook entries have timestamps but no <code>valid_until</code> or <code>schema_fingerprint</code>. Load-bearing: when a schema migration changes a column, stale playbooks silently keep boosting. Biggest-value remaining fix.</li>
<li><strong>Mem0-style UPDATE / DELETE / NOOP ops</strong> — <code>/seed</code> only ADDs. Same (operation, date) pair appends a duplicate instead of refining an existing entry. Playbook file grows faster than necessary.</li>
<li><strong>Letta working-memory hot cache</strong> — every query scans all 1560 playbook entries from disk. Cheap today at 1.5K, not at 100K. Solution: LRU in-process cache for the last N playbooks or the current sig's neighborhood.</li>
</ul>
<p>Validity windows is next — preserves the trust signal (boost only fires on playbooks that are still true given the current schema) rather than the latency signal (which the current scale doesn't need yet).</p>
<div class="ref"><strong>Code:</strong> crates/vectord/src/{playbook_memory.rs, service.rs} · tests/multi-agent/{kb.ts, memory_query.ts, normalize.ts, scenario.ts} · mcp-server/{observer.ts, index.ts} · data/_kb/ · data/_observer/</div>
</div>
<!-- ═══ 7. SCALE STORY ═══ -->
@@ -297,7 +364,7 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
<li><strong>Ollama inference is serial.</strong> Embedding 1M rows at ~50 chunks/sec through nomic-embed-text = ~6 hours. Acceptable for overnight refresh, not for "immediate." Mitigated by incremental refresh (only deltas).</li>
<li><strong>RAM ceiling on HNSW.</strong> Around 5M vectors × 768d, HNSW stops fitting in 128GB comfortably. Mitigation: per-profile <code>vector_backend: lance</code> flip — disk-resident IVF_PQ scales past the RAM line (ADR-019).</li>
<li><strong>VRAM ceiling for model variety.</strong> A4000 16GB holds 1-2 loaded models. Multi-model recruiter surfaces are a sequential swap, not parallel (Ollama <code>keep_alive=0</code>). Phase 17 profile activation unloads the prior model on swap.</li>
<li><strong>playbook_memory growth.</strong> Currently unbounded. 391 entries today at this rate becomes ~5K in six months. Default k=200 still sub-ms at 5K. Compaction policy (TTL + decay + merge) deferred.</li>
<li><strong>playbook_memory growth.</strong> 1936 entries today. Phase 25 (2026-04-21) added retirement via <code>valid_until</code> + <code>schema_fingerprint</code> fields + <code>POST /vectors/playbook_memory/retire</code> endpoint (manual or schema-drift triggered). Active vs retired split surfaced on <code>GET /vectors/playbook_memory/status</code>. Brute-force cosine still sub-ms at current size; Letta-style working-memory hot cache deferred until entry count crosses ~100K.</li>
</ul>
</div>
@@ -320,6 +387,10 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
<tr><td>Ollama sidecar down</td><td>502 Bad Gateway from aibridge; <code>embed</code> calls fail fast</td><td>Restart: <code>systemctl restart lakehouse-sidecar</code>; vector search falls back to pre-computed embeddings</td></tr>
<tr><td>Gateway restart mid-operation</td><td>In-memory state (playbook_memory, HNSW) reloaded from persisted <code>state.json</code> / trial journals</td><td>Zero data loss; catalog, storage, journals are all source-of-truth</td></tr>
<tr><td>Schema fingerprint diverges across manifests</td><td><code>catalog::dedupe</code> reports <code>DedupeReport</code> with winner selection (non-null row_count first, then newest updated_at)</td><td><code>POST /catalog/dedupe</code> collapses duplicates idempotently</td></tr>
<tr><td>Scenario event fails on zero-supply city</td><td>Cloud rescue (Phase 22 item B) fires — <code>gpt-oss:120b</code> sees SQL filters attempted, row counts, reviewer drift notes, contract terms; returns structured <code>{retry, new_city, new_state, new_role, new_count, rationale}</code></td><td>Retry with pivot runs same executor loop with new geography; verified Gary IN → South Bend IN filled 1/1 after original drift-abort</td></tr>
<tr><td>LLM response truncated mid-JSON (thinking model ate token budget)</td><td>Phase 21 <code>generateContinuable()</code> detects via brace-balance + JSON.parse; no silent truncation</td><td>Auto-continue with partial as scratchpad, or geometric backoff if initial call returned empty. Bounded by <code>max_continuations</code>.</td></tr>
<tr><td>Schema migration invalidates existing playbooks</td><td>Phase 25 — <code>POST /vectors/playbook_memory/retire</code> with current fingerprint retires all mismatched entries in scope; diagnostic log shows counts</td><td>Retired entries stay in journal for forensics but are skipped by all boost calculations. Scoped by (city, state) so unrelated geos aren't touched.</td></tr>
<tr><td>Observer fails to reach scenario outcome stream</td><td>Scenario <code>postObserverEvent()</code> uses 2s <code>AbortSignal.timeout</code>; silent skip if :3800 is down</td><td>Scenario log is still the source of truth; observer re-ingest on next run restores the stream. <code>data/_observer/ops.jsonl</code> is append-only so prior events survive.</td></tr>
</tbody>
</table>
</div>
@@ -344,6 +415,15 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
<h3>Daily summary per staffer</h3>
<p>Workspace activity log + per-staffer filter on the event journal gives <strong>"what did Sarah do today"</strong> as a direct query. The foundation for shift-handoff reports.</p>
<h3>Staffer identity + competence-weighted retrieval (Phase 23)</h3>
<p>Each scenario run carries an explicit <code>staffer: {id, name, tenure_months, role, tool_level}</code>. The KB aggregates per-staffer stats (<code>data/_kb/staffers.jsonl</code>) that roll up into a single <code>competence_score</code>:</p>
<pre>competence_score = 0.45·fill_rate + 0.20·turn_efficiency + 0.20·citation_density + 0.15·rescue_rate</pre>
<p>When any query runs <code>kb.findNeighbors(spec, k)</code>, the ranking isn't just cosine similarity — it's <code>weighted_score = cosine × max_staffer_competence</code> over the best coordinator who ran that signature. Senior staffers' playbooks surface above juniors' on similar scenarios, even when the juniors' scenario was marginally closer in embedding space.</p>
<p>The <code>tool_level</code> knob (full / local / basic / minimal) controls which tiers are available to a given staffer's runs. See Ch 3 for the mapping. Variance is real and measurable: 36-run demo produced a 46pt fill-rate delta between James (local tools, 93%) and Alex (minimal tools, 60%) on identical contracts.</p>
<h3>Auto-discovered reliable-performer labels</h3>
<p>A second-order output of the competence path: when multiple staffers independently endorse the same worker on similar-signature playbooks, that worker accumulates cross-staffer endorsements. <code>scripts/kb_staffer_report.py</code> surfaces them — after 36 runs, Rachel D. Lewis (Welder Nashville) had 18 endorsements across 4 staffers, Angela U. Ward (Machine Op Indianapolis) 19. These are high-confidence "reliable" labels the system produced without human tagging. The UI could badge these workers on future queries; today they're visible via <code>/memory/query</code>'s <code>discovered_patterns</code> bundle.</p>
</div>
<!-- ═══ 10. A DAY IN THE LIFE ═══ -->
@@ -370,9 +450,11 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
<div class="step"><div class="n">15:00</div><div class="body"><strong>New embeddings live.</strong> Hot-swap promotion. Searches now see all 1M new profiles. Sarah's noon query re-run would produce different top-5.</div></div>
<div class="step"><div class="n">17:00</div><div class="body"><strong>End-of-day retrospective.</strong> Any staffer who ran <code>tests/multi-agent/scenario.ts</code> gets <code>report.md</code> auto-generated. Workspace activity logs aggregate per staffer. <code>GET /vectors/playbook_memory/stats</code> shows the day's new entries.</div></div>
<div class="step"><div class="n">17:00</div><div class="body"><strong>End-of-day retrospective.</strong> Any staffer who ran <code>tests/multi-agent/scenario.ts</code> gets <code>report.md</code> auto-generated. Workspace activity logs aggregate per staffer. <code>GET /vectors/playbook_memory/status</code> shows active vs retired counts. KB indexes the run (<code>kb.indexRun</code>) and the overview model synthesizes a pathway recommendation for the next matching signature. Every event outcome has already streamed to <code>lakehouse-observer.service</code> on :3800 for ERROR_ANALYZER + PLAYBOOK_BUILDER consumption.</div></div>
<div class="step"><div class="n">22:00</div><div class="body"><strong>Overnight trial cycle.</strong> Autotune agent continues in the background. Trial journal grows. Tomorrow morning, the system is measurably better at something it got asked about today.</div></div>
<div class="step"><div class="n">17:15</div><div class="body"><strong>Kim fires a natural-language query from the search box.</strong> "need 3 forklift operators in Joliet by Monday" → <code>POST /memory/query</code> on the Bun MCP. Regex normalizer extracts role / city / count / deadline / intent in 0ms (no LLM call). The unified response returns playbook workers (auto-surfaced reliable performers for Joliet Forklift with citation counts), pathway recommendation from KB, prior T3 lessons for Joliet, and top staffers by competence — all in ~300ms.</div></div>
<div class="step"><div class="n">22:00</div><div class="body"><strong>Overnight trial cycle.</strong> Autotune agent continues in the background. Trial journal grows. KB's <code>detectErrorCorrections</code> scans today's outcomes for fail→succeed deltas on the same signature; any correction gets logged to <code>data/_kb/error_corrections.jsonl</code> with the config diff. Tomorrow morning, the system is measurably better at something it got asked about today.</div></div>
<h3>SMS + email drafts in the pipeline</h3>
<p>After each sealed fill (via scenario.ts or manual <code>/log</code> flow with downstream hooks), <code>generateArtifacts</code> in the scenario runner produces: (a) one SMS per worker (TO: Name, message under 180 chars), (b) one client confirmation email. Drafts are saved to <code>sms.md</code> and <code>emails.md</code> under the scenario output dir. Ollama drafts them; the staffer reviews and sends. No auto-send; human-in-the-loop.</p>
@@ -386,11 +468,13 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
<h4>Deferred — real architectural work, just not shipped yet</h4>
<ul>
<li><strong>Rate / margin awareness.</strong> Worker pay expectations vs contract bill rate not modeled. Requires adding <code>pay_rate</code> to workers, <code>bill_rate</code> to contracts, and a filter + warning path. Phase 20 item.</li>
<li><strong>Push / background presence.</strong> The app requires being opened. No Slack/email/SMS push when a contract lands with a pre-ranked candidate list. Would make the "system is already thinking" claim more visible to phone-first shops.</li>
<li><strong>Rate / margin awareness.</strong> Worker pay expectations vs contract bill rate not modeled. Requires adding <code>pay_rate</code> to workers, <code>bill_rate</code> to contracts, and a filter + warning path. Partially addressed via <code>ContractTerms.budget_per_hour_max</code> passed to T3/rescue prompts, but the match-time filter isn't wired yet.</li>
<li><strong>Mem0-style UPDATE / DELETE / NOOP operations on playbooks.</strong> Today <code>/seed</code> only ADDs. Same <code>(operation, date)</code> pair appends a duplicate instead of refining an existing entry. Phase 26 item — cheap to add, moderate payoff.</li>
<li><strong>Letta working-memory hot cache.</strong> Every boost query scans all active playbook entries from in-memory state. 1.9K today; cheap. Will bite somewhere north of 100K. LRU for the last-N playbooks or current-sig neighborhood deferred until that ceiling approaches.</li>
<li><strong>Chunking cache (Phase 21 Rust port).</strong> TS primitives <code>generateContinuable</code> + <code>generateTreeSplit</code> are wired, but <code>crates/aibridge/src/{continuation.rs, tree_split.rs}</code> + <code>crates/storaged/src/chunk_cache.rs</code> remain queued. Gateway-side callers currently don't have the same protection against silent truncation that the TS test harness does.</li>
<li><strong>Confidence calibration.</strong> Top-K is a rank, not a probability. No calibrated "85% likely to accept" score. Requires outcome-labeled training data.</li>
<li><strong>Neural re-ranker.</strong> Phase 19 is statistical + semantic. A (query, candidate, outcome)-trained re-ranker is deferred to Phase 20+ per ADR, only if the statistical floor plateaus below usable recall.</li>
<li><strong>playbook_memory compaction.</strong> No TTL or merge policy. Entries accumulate. At expected rate this hits 10K in a year — still tractable but warrants a policy.</li>
<li><strong>Neural re-ranker.</strong> Phase 19 is statistical + semantic (now with geo + role prefilter, Phase 25 retirement). A (query, candidate, outcome)-trained re-ranker is deferred only if the statistical floor plateaus below usable recall — current 14× citation lift on identical inputs suggests it hasn't.</li>
<li><strong>Observer → autotune feedback wire.</strong> Phase 24 streams scenario outcomes into <code>data/_observer/ops.jsonl</code>; autotune agent still runs on its own HNSW-trial schedule and hasn't subscribed to the outcome metric stream yet. Phase 26+ item — connects the last loop.</li>
<li><strong>call_log cross-reference.</strong> Infrastructure present; current synthetic candidates table is too small to cross-ref. Fixes when real ATS lands.</li>
</ul>
@@ -412,6 +496,6 @@ boost[(city, state, name)] = min(Σ per_worker, 0.25)</pre>
</div>
</div>
<div class="footer">Lakehouse spec · v1 2026-04-20 · maintained from <code>docs/DECISIONS.md</code> · <a href="proof">architecture live-tested</a> · <a href="console">walkthrough</a></div>
<div class="footer">Lakehouse spec · v2 2026-04-21 · Phases 19-25 shipped (playbook boost, model matrix, continuation, KB, staffer competence, observer ingest, validity windows) · maintained from <code>docs/DECISIONS.md</code> · <a href="proof">architecture live-tested</a> · <a href="console">walkthrough</a></div>
</body></html>
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# A/B test of T3 overseer: does it actually make subsequent runs better?
# Chains Run B (T3 seed) → Run C (T3 + read-back) → Run D (T3 cloud).
# Run A is assumed already complete (launched separately). Aggregates
# metrics at the end into ab_scorecard.json.
set -e
cd "$(dirname "$0")/.."
export OLLAMA_CLOUD_KEY="$(python3 -c "import json; print(json.load(open('/root/llm_team_config.json'))['providers']['ollama_cloud']['api_key'])")"
echo "▶ A/B test start at $(date -Iseconds)"
echo "▶ prior lessons dir: $(ls data/_playbook_lessons 2>/dev/null | wc -l) files"
# Run B — T3 enabled local, no prior lessons should exist yet
echo "──── RUN B: T3 local, seeds first lesson ────"
bun tests/multi-agent/scenario.ts > /tmp/lakehouse_ab_B.log 2>&1 || true
echo " B exit=$?"
ls data/_playbook_lessons/*.json 2>/dev/null | head -5
# Run C — T3 enabled local, B's lesson should load
echo "──── RUN C: T3 local, reads B's lesson ────"
bun tests/multi-agent/scenario.ts > /tmp/lakehouse_ab_C.log 2>&1 || true
echo " C exit=$?"
# Run D — T3 enabled CLOUD (gpt-oss:120b), reads B+C lessons
echo "──── RUN D: T3 cloud, reads B+C lessons ────"
LH_OVERVIEW_CLOUD=1 bun tests/multi-agent/scenario.ts > /tmp/lakehouse_ab_D.log 2>&1 || true
echo " D exit=$?"
echo "▶ all runs done at $(date -Iseconds)"
echo "▶ scorecard:"
ls -1dt tests/multi-agent/playbooks/scenario-* | head -4 | tac | python3 -c "
import sys, os, json
runs = [l.strip() for l in sys.stdin if l.strip()]
labels = ['A(no-T3)','B(T3-seed)','C(T3-read)','D(T3-cloud)']
# Prepend Run A: most recent BEFORE the ab_t3_test kicked off is Run A
# (launched separately). But we only picked up the most recent 4 runs.
# Actually: ab_t3_test runs B/C/D, so recent 3 = B,C,D. Run A is the one
# BEFORE those — find it separately.
# Reread to include Run A:
import subprocess
all_runs = subprocess.check_output(['bash','-c','ls -1dt tests/multi-agent/playbooks/scenario-* | head -8']).decode().strip().split('\n')
# The 4 most recent are D, C, B, A (reverse chronological).
top4 = list(reversed(all_runs[:4])) # oldest first → A,B,C,D
rows = []
for i, path in enumerate(top4):
try:
results = json.load(open(os.path.join(path, 'results.json')))
except FileNotFoundError:
continue
ok = sum(1 for r in results if r.get('ok'))
turns = sum(r.get('turns', 0) for r in results)
gaps = sum(len(r.get('gap_signals', [])) for r in results)
cites = sum(len(r.get('playbook_citations') or []) for r in results)
prior = []
try:
prior = json.load(open(os.path.join(path, 'prior_lessons.json')))
except FileNotFoundError:
pass
rows.append({
'label': labels[i] if i < len(labels) else f'run{i}',
'path': path,
'ok_events': ok,
'total_events': len(results),
'total_turns': turns,
'total_gaps': gaps,
'total_citations': cites,
'prior_lessons_loaded': len(prior),
})
scorecard = {'generated_at': __import__('datetime').datetime.utcnow().isoformat()+'Z', 'runs': rows}
open('tests/multi-agent/playbooks/ab_scorecard.json','w').write(json.dumps(scorecard, indent=2))
print(json.dumps(scorecard, indent=2))
"
echo "▶ saved: tests/multi-agent/playbooks/ab_scorecard.json"
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env python3
"""Aggregate KB state for item 3 decision.
Reads data/_kb/*.jsonl and tests/multi-agent/playbooks/*/results.json
to answer:
- How many distinct signatures exist?
- Total runs, avg ok rate, avg citations per event?
- Which (role, city) combos have NEVER gotten a citation?
- Recommender confidence progression (cold → medium → high)?
- Mean turn count trend across runs (proxy for efficiency).
Run after `scripts/run_kb_batch.sh` completes. Writes a markdown
summary to tests/multi-agent/playbooks/kb_measurement.md and prints
to stdout.
"""
import json
import os
import sys
from collections import Counter, defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
KB = ROOT / "data" / "_kb"
PLAYBOOKS = ROOT / "tests" / "multi-agent" / "playbooks"
def load_jsonl(p):
if not p.exists():
return []
out = []
for line in p.read_text().splitlines():
if line.strip():
try:
out.append(json.loads(line))
except json.JSONDecodeError:
pass
return out
def main():
sigs = load_jsonl(KB / "signatures.jsonl")
outcomes = load_jsonl(KB / "outcomes.jsonl")
recs = load_jsonl(KB / "pathway_recommendations.jsonl")
corrections = load_jsonl(KB / "error_corrections.jsonl")
# --- Basic counts ---
print(f"Signatures: {len(sigs)}")
print(f"Outcomes: {len(outcomes)}")
print(f"Recommendations: {len(recs)}")
print(f"Error corrections: {len(corrections)}")
print()
# --- Recommender confidence progression ---
conf_counts = Counter(r.get("confidence", "?") for r in recs)
print(f"Recommender confidence distribution:")
for c in ("high", "medium", "low"):
print(f" {c:8s}: {conf_counts.get(c, 0)}")
print()
# Time-ordered confidence
recs_sorted = sorted(recs, key=lambda r: r.get("generated_at", ""))
neighbor_counts = [len(r.get("neighbors_consulted", [])) for r in recs_sorted]
if neighbor_counts:
print(f"Neighbors consulted over time (first → last):")
print(f" first 3: {neighbor_counts[:3]}")
print(f" last 3: {neighbor_counts[-3:]}")
print(f" max: {max(neighbor_counts)}")
print()
# --- Fill rate + citation density per run ---
if outcomes:
total_ok = sum(o["ok_events"] for o in outcomes)
total_events = sum(o["total_events"] for o in outcomes)
total_cites = sum(o.get("total_citations", 0) for o in outcomes)
total_turns = sum(o.get("total_turns", 0) for o in outcomes)
print(f"Fill rate: {total_ok}/{total_events} = {100*total_ok/max(1,total_events):.1f}%")
print(f"Avg citations per run: {total_cites/len(outcomes):.2f}")
print(f"Avg turns per run: {total_turns/len(outcomes):.1f}")
print()
# First 5 runs vs last 5 — does it get better?
sorted_out = sorted(outcomes, key=lambda o: o.get("created_at", ""))
if len(sorted_out) >= 10:
first = sorted_out[:5]
last = sorted_out[-5:]
fok = sum(o["ok_events"] for o in first) / sum(o["total_events"] for o in first)
lok = sum(o["ok_events"] for o in last) / sum(o["total_events"] for o in last)
fcit = sum(o.get("total_citations", 0) for o in first) / 5
lcit = sum(o.get("total_citations", 0) for o in last) / 5
print(f"First 5 runs ok rate: {100*fok:.1f}% avg cites: {fcit:.2f}")
print(f"Last 5 runs ok rate: {100*lok:.1f}% avg cites: {lcit:.2f}")
print()
# --- Per-(role, city) citation coverage ---
cite_by_combo = Counter()
combo_attempts = Counter()
for o in outcomes:
for ev in o.get("per_event", []):
key = (ev.get("role", "?"), "?") # city not in per_event summary
combo_attempts[key] += 1
# Read the playbook dirs for full event detail (has city)
cites_by_role_city = defaultdict(lambda: {"attempts": 0, "citations": 0, "ok": 0})
for o in outcomes:
run_dir = PLAYBOOKS / o["run_id"]
results_file = run_dir / "results.json"
if not results_file.exists():
continue
try:
results = json.loads(results_file.read_text())
except Exception:
continue
for r in results:
e = r.get("event", {})
key = (e.get("role"), e.get("city"), e.get("state"))
cites_by_role_city[key]["attempts"] += 1
cites_by_role_city[key]["citations"] += len(r.get("playbook_citations") or [])
if r.get("ok"):
cites_by_role_city[key]["ok"] += 1
combos_with_cites = [(k, v) for k, v in cites_by_role_city.items() if v["citations"] > 0]
combos_zero_cites = [(k, v) for k, v in cites_by_role_city.items() if v["citations"] == 0 and v["ok"] > 0]
print(f"(role, city, state) combos with any citation: {len(combos_with_cites)}")
print(f"(role, city, state) combos with ok fills but 0 cites: {len(combos_zero_cites)}")
print()
if combos_with_cites:
print("Top 10 combos by citation count:")
for (role, city, state), v in sorted(combos_with_cites, key=lambda x: -x[1]["citations"])[:10]:
print(f" {role:25s} {city:15s} {state}: {v['citations']} cites across {v['attempts']} attempts ({v['ok']} ok)")
print()
# --- Write markdown report ---
lines = ["# KB Measurement Report", ""]
lines.append(f"Generated from {len(outcomes)} runs across {len(sigs)} distinct signatures.")
lines.append("")
lines.append("## Recommender confidence")
for c in ("high", "medium", "low"):
lines.append(f"- {c}: {conf_counts.get(c, 0)}")
lines.append("")
lines.append("## Overall fill + citation")
if outcomes:
lines.append(f"- Fill rate: **{total_ok}/{total_events}** ({100*total_ok/max(1,total_events):.1f}%)")
lines.append(f"- Avg citations per run: **{total_cites/len(outcomes):.2f}**")
lines.append(f"- Avg turns per run: {total_turns/len(outcomes):.1f}")
lines.append("")
lines.append("## Citation coverage by (role, city, state)")
lines.append(f"- Combos with ≥1 citation: {len(combos_with_cites)}")
lines.append(f"- Combos with ok fills but 0 citations: {len(combos_zero_cites)}")
lines.append("")
lines.append("## Item 3 decision signal")
if combos_zero_cites:
lines.append("Non-zero: there are **combos that succeeded but never triggered playbook_memory boost**. Candidates for item 3 investigation:")
for (role, city, state), v in combos_zero_cites[:5]:
lines.append(f"- {role} in {city}, {state}: {v['ok']}/{v['attempts']} ok, 0 cites")
else:
lines.append("All ok combos got at least some citation firing. Boost mechanism is healthy; raising the cap may help but isn't forced.")
lines.append("")
out = PLAYBOOKS / "kb_measurement.md"
out.write_text("\n".join(lines))
print(f"✓ markdown report → {out}")
if __name__ == "__main__":
main()
+108
View File
@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Phase 23 staffer leaderboard + cross-staffer pattern finder.
Reads data/_kb/staffers.jsonl + outcomes.jsonl + signatures.jsonl and
emits:
- Leaderboard sorted by competence_score
- Per-staffer breakdown (fill rate, turns, citations, rescue rate)
- Cross-staffer common workers: names endorsed by multiple top
staffers on similar signatures — candidates for "auto-discovered
high-value worker" labels.
Run after scripts/run_staffer_demo.sh completes.
"""
import json
import os
from collections import defaultdict, Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
KB = ROOT / "data" / "_kb"
PLAYBOOKS = ROOT / "tests" / "multi-agent" / "playbooks"
def load_jsonl(p):
if not p.exists():
return []
out = []
for line in p.read_text().splitlines():
if line.strip():
try:
out.append(json.loads(line))
except json.JSONDecodeError:
pass
return out
def main():
staffers = load_jsonl(KB / "staffers.jsonl")
outcomes = load_jsonl(KB / "outcomes.jsonl")
if not staffers:
print("(no staffer stats yet — run scripts/run_staffer_demo.sh first)")
return
staffers.sort(key=lambda s: -s["competence_score"])
print("=== STAFFER LEADERBOARD ===")
print(f"{'rank':4s} {'id':7s} {'name':16s} {'role':8s} {'mo':>4s} {'runs':>4s} {'fill':>6s} {'turns':>6s} {'cites':>6s} {'rescue':>7s} {'score':>6s}")
print("-" * 100)
for i, s in enumerate(staffers):
rank_mark = "★" if i == 0 else " "
print(f"{rank_mark}{i+1:3d} {s['id']:7s} {s['name']:16s} {s['role']:8s} {s['tenure_months']:>4d} {s['total_runs']:>4d} {s['fill_rate']*100:>5.1f}% {s['avg_turns_per_event']:>6.1f} {s['avg_citations_per_run']:>6.2f} {s['rescue_rate']*100:>6.1f}% {s['competence_score']:>6.3f}")
print()
# Cross-staffer pattern — workers endorsed on the same sig_hash
# across multiple staffers. Auto-discovered "reliable performers".
print("=== CROSS-STAFFER WORKER OVERLAP ===")
print("(workers endorsed on same sig_hash by ≥2 staffers)")
worker_touches = defaultdict(lambda: {"staffers": set(), "sigs": set(), "endorsements": 0})
for o in outcomes:
run_dir = PLAYBOOKS / o["run_id"]
results_file = run_dir / "results.json"
if not results_file.exists():
continue
try:
results = json.loads(results_file.read_text())
except Exception:
continue
staffer_id = o.get("staffer", {}).get("id")
if not staffer_id:
continue
for r in results:
if not r.get("ok"):
continue
for f in r.get("fills", []):
name = f.get("name")
if not name or name.startswith("Candidate "):
continue
key = (name, r["event"]["role"], r["event"]["city"], r["event"]["state"])
worker_touches[key]["staffers"].add(staffer_id)
worker_touches[key]["sigs"].add(o["sig_hash"])
worker_touches[key]["endorsements"] += 1
shared = sorted(
[(k, v) for k, v in worker_touches.items() if len(v["staffers"]) >= 2],
key=lambda x: -x[1]["endorsements"],
)
if shared:
for (name, role, city, state), v in shared[:15]:
print(f" {name:25s} {role:22s} {city:15s} {state}: {v['endorsements']} endorsements across {len(v['staffers'])} staffers")
else:
print(" (none yet — needs ≥2 staffers on overlapping scenarios)")
print()
# Competence differential — how much does top vs bottom differ?
if len(staffers) >= 2:
top = staffers[0]
bot = staffers[-1]
print(f"=== TOP vs BOTTOM DIFFERENTIAL ===")
print(f"{top['name']} (top) vs {bot['name']} (bottom):")
print(f" fill rate: {top['fill_rate']*100:.1f}% vs {bot['fill_rate']*100:.1f}% (Δ {(top['fill_rate']-bot['fill_rate'])*100:+.1f}pt)")
print(f" avg turns: {top['avg_turns_per_event']:.1f} vs {bot['avg_turns_per_event']:.1f} (Δ {top['avg_turns_per_event']-bot['avg_turns_per_event']:+.1f})")
print(f" avg citations: {top['avg_citations_per_run']:.2f} vs {bot['avg_citations_per_run']:.2f} (Δ {top['avg_citations_per_run']-bot['avg_citations_per_run']:+.2f})")
print(f" rescue rate: {top['rescue_rate']*100:.1f}% vs {bot['rescue_rate']*100:.1f}%")
print(f" competence: {top['competence_score']:.3f} vs {bot['competence_score']:.3f}")
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Run all generated scenarios sequentially to populate the KB.
# Reads tests/multi-agent/scenarios/manifest.json and feeds each file
# to scenario.ts. Each scenario indexes into data/_kb/ automatically
# via the end-of-run hook. Exit code: 0 if all scenarios completed
# (event failures are NOT failures for the batch — we want the KB to
# record both successes AND failures).
set -e
cd "$(dirname "$0")/.."
export OLLAMA_CLOUD_KEY="$(python3 -c "import json; print(json.load(open('/root/llm_team_config.json'))['providers']['ollama_cloud']['api_key'])" 2>/dev/null || echo '')"
MANIFEST="tests/multi-agent/scenarios/manifest.json"
if [ ! -f "$MANIFEST" ]; then
echo "✗ no manifest at $MANIFEST — run: bun tests/multi-agent/gen_scenarios.ts <N>"
exit 1
fi
START_TS=$(date -Iseconds)
LOG_DIR="/tmp/lakehouse_kb_batch_$(date +%s)"
mkdir -p "$LOG_DIR"
echo "▶ KB batch start: $START_TS, logs → $LOG_DIR"
python3 -c "
import json
m = json.load(open('$MANIFEST'))
for s in m['scenarios']:
print(s['file'])
" | while read -r SCEN; do
SPEC="tests/multi-agent/scenarios/$SCEN"
BASE=$(basename "$SPEC" .json)
LOG="$LOG_DIR/${BASE}.log"
echo " ▶ $SCEN"
bun tests/multi-agent/scenario.ts "$SPEC" > "$LOG" 2>&1 || true
OK=$(grep -oP '\d+/\d+ events succeeded' "$LOG" | tail -1 || echo "no-result")
SIG=$(grep -oP 'KB indexed: sig=\K[a-f0-9]+' "$LOG" | tail -1 || echo "-")
echo " → $OK; sig=$SIG"
done
echo "▶ KB batch done: $(date -Iseconds)"
echo "▶ KB state:"
wc -l data/_kb/*.jsonl 2>/dev/null || true
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Run the 12 staffer-demo scenarios (4 personas × 3 contracts) with
# cloud T3 enabled. After each run, KB indexes the outcome and
# recomputes that staffer's competence_score. By the end, the top
# staffer's playbooks will be surfacing first in neighbor retrieval.
set -e
cd "$(dirname "$0")/.."
export OLLAMA_CLOUD_KEY="$(python3 -c "import json; print(json.load(open('/root/llm_team_config.json'))['providers']['ollama_cloud']['api_key'])" 2>/dev/null || echo '')"
MANIFEST="tests/multi-agent/scenarios/staffer_demo/manifest.json"
if [ ! -f "$MANIFEST" ]; then
echo "✗ no manifest — run: bun tests/multi-agent/gen_staffer_demo.ts"
exit 1
fi
START_TS=$(date -Iseconds)
LOG_DIR="/tmp/lakehouse_staffer_demo_$(date +%s)"
mkdir -p "$LOG_DIR"
echo "▶ Staffer demo start: $START_TS, logs → $LOG_DIR"
python3 -c "
import json
m = json.load(open('$MANIFEST'))
for s in m['scenarios']:
print(s['file'], '|', s['staffer'], '|', s['contract'])
" | while IFS='|' read -r SCEN STAFFER CONTRACT; do
SCEN=$(echo "$SCEN" | xargs)
STAFFER=$(echo "$STAFFER" | xargs)
CONTRACT=$(echo "$CONTRACT" | xargs)
SPEC="tests/multi-agent/scenarios/staffer_demo/$SCEN"
BASE=$(basename "$SPEC" .json)
LOG="$LOG_DIR/${BASE}.log"
echo " ▶ $STAFFER × $CONTRACT"
LH_OVERVIEW_CLOUD=1 bun tests/multi-agent/scenario.ts "$SPEC" > "$LOG" 2>&1 || true
OK=$(grep -oP '\d+/\d+ events succeeded' "$LOG" | tail -1 || echo "no-result")
RESCUES=$(grep -c "cloud rescue requested" "$LOG" || true)
RESCUE_OK=$(grep -c "retry outcome: ✓" "$LOG" || true)
SIG=$(grep -oP 'KB indexed: sig=\K[a-f0-9]+' "$LOG" | tail -1 || echo "-")
echo " → $OK; rescues=$RESCUES (${RESCUE_OK} succeeded); sig=$SIG"
done
echo "▶ Staffer demo done: $(date -Iseconds)"
echo "▶ Staffer competence leaderboard:"
python3 scripts/kb_staffer_report.py 2>/dev/null || echo "(run scripts/kb_staffer_report.py after batch completes)"
+6
View File
@@ -16,6 +16,10 @@ class GenerateRequest(BaseModel):
system: str | None = None
temperature: float = 0.7
max_tokens: int = 2048
# think=false disables hidden reasoning blocks on thinking models
# (qwen3, qwen3.5, gpt-oss). Required for hot-path JSON emitters
# that need the whole token budget for the visible response.
think: bool | None = None
class GenerateResponse(BaseModel):
@@ -40,6 +44,8 @@ async def generate(req: GenerateRequest):
}
if req.system:
payload["system"] = req.system
if req.think is not None:
payload["think"] = req.think
async with client() as c:
resp = await c.post("/api/generate", json=payload)
+241 -13
View File
@@ -22,6 +22,214 @@ export function estimateTokens(text: string): number {
return Math.ceil(text.length / 4);
}
// ============================================================
// Scratchpad + tree-split continuation
// ============================================================
//
// Core problem: when a prompt OR response would exceed a model's window
// — e.g. 200 playbooks pasted in, or a long propose_done payload — we
// cannot just raise max_tokens forever. That's the tourniquet approach
// J explicitly rejected. The right answer is: split into sub-calls
// glued by a persistent scratchpad, so no context is ever lost.
//
// Two primitives:
//
// 1. generateContinuable(): for OUTPUT overflow. If the model returns
// a structurally-incomplete JSON (unmatched braces, truncated
// mid-value), auto-continue with the partial as scratchpad until
// the JSON parses or max_continuations is hit.
//
// 2. generateTreeSplit(): for INPUT overflow. If prompt + system +
// max_tokens exceeds window, shard the input at semantic boundaries,
// run each shard through the model with a running scratchpad digest,
// then run a final reduce pass to combine. This is map-reduce with
// glue.
//
// Both are tier-agnostic — they wrap generate() and generateCloud().
// Check structural completeness of a response. For non-JSON responses
// (lesson text, checkpoint hints) we treat "non-empty" as complete.
// For JSON-shaped responses (executor/reviewer actions) we check that
// braces match AND that JSON.parse succeeds on the first {...} block.
function isStructurallyComplete(text: string, shape: "json" | "text"): boolean {
if (!text || !text.trim()) return false;
if (shape === "text") return true;
// Strip ``` fences
let s = text.trim();
if (s.startsWith("```")) s = s.replace(/^```(?:json)?\n?/, "").replace(/```$/, "").trim();
const start = s.indexOf("{");
const end = s.lastIndexOf("}");
if (start < 0 || end <= start) return false;
const slice = s.slice(start, end + 1);
// Balance check — cheaper than JSON.parse and catches truncated nests
let depth = 0, inStr = false, esc = false;
for (const c of slice) {
if (esc) { esc = false; continue; }
if (c === "\\") { esc = true; continue; }
if (c === '"') { inStr = !inStr; continue; }
if (inStr) continue;
if (c === "{") depth++;
else if (c === "}") depth--;
}
if (depth !== 0) return false;
try { JSON.parse(slice); return true; } catch { return false; }
}
// Continue a truncated response. We do NOT ask the model to start over —
// we ask it to continue from exactly where it stopped. The partial goes
// in as scratchpad so it knows what's already committed.
async function continueResponse(
model: string,
originalPrompt: string,
partial: string,
opts: { max_tokens: number; temperature: number; system?: string; cloud: boolean; think?: boolean },
): Promise<string> {
const continuationPrompt = `${originalPrompt}
PARTIAL RESPONSE SO FAR (continue from here — do NOT restart, do NOT repeat what's already there, emit ONLY the remaining tokens to close the structure):
${partial}`;
const fn = opts.cloud ? generateCloud : generate;
const rest = await fn(model, continuationPrompt, {
max_tokens: opts.max_tokens,
temperature: opts.temperature,
system: opts.system,
bypass_budget: true, // the caller already checked; continuation doesn't double-count
think: opts.think,
});
return partial + rest;
}
// Output-overflow handler. Handles two distinct failure modes:
// (a) EMPTY response — thinking model ate the whole budget. Retry the
// ORIGINAL prompt with 2x the budget (geometric backoff up to cap).
// (b) TRUNCATED non-empty — model got most of the way there but hit
// max_tokens before closing. Continue with the partial as
// scratchpad.
// The two modes need different repair: (a) needs more budget, not
// continuation from ""; (b) needs scratchpad-glued continuation.
export async function generateContinuable(
model: string,
prompt: string,
opts: {
max_tokens?: number;
temperature?: number;
system?: string;
shape?: "json" | "text";
max_continuations?: number;
cloud?: boolean;
think?: boolean;
on_continuation?: (n: number, combined_len: number) => void;
} = {},
): Promise<string> {
const shape = opts.shape ?? "json";
const initialMax = opts.max_tokens ?? 800;
const maxConts = opts.max_continuations ?? 3;
const cloud = opts.cloud ?? false;
const budgetCap = 8000; // don't geometric-backoff forever
const fn = cloud ? generateCloud : generate;
let combined = "";
let currentMax = initialMax;
// Initial call + empty-response backoff loop.
for (let retry = 0; retry < 3; retry++) {
const out = await fn(model, prompt, {
max_tokens: currentMax,
temperature: opts.temperature,
system: opts.system,
think: opts.think,
});
if (out.trim().length > 0) { combined = out; break; }
// Empty — thinking model ate the budget. Double it and retry.
if (opts.on_continuation) opts.on_continuation(retry + 1, 0);
currentMax = Math.min(currentMax * 2, budgetCap);
}
// Structural completion loop (continuation from partial).
for (let i = 0; i < maxConts; i++) {
if (isStructurallyComplete(combined, shape)) return combined;
if (opts.on_continuation) opts.on_continuation(i + 1, combined.length);
combined = await continueResponse(model, prompt, combined, {
max_tokens: Math.min(currentMax, budgetCap),
temperature: opts.temperature ?? 0.3,
system: opts.system,
cloud,
think: opts.think,
});
}
// Last-resort: return combined even if incomplete; caller's parser
// will throw with the raw text for forensics rather than silently
// truncating.
return combined;
}
// Input-overflow handler. When the input corpus exceeds the window,
// shard → map → reduce with a running scratchpad digest.
//
// shards: array of input chunks the caller already split at semantic
// boundaries (paragraphs, records, playbook entries).
// map_prompt: fn taking (shard, running_scratchpad) → prompt for a
// single map call. Must fit within window.
// reduce_prompt: fn taking (combined_scratchpad) → final prompt. Also
// must fit window; if the scratchpad itself overflows, this triggers
// a recursive tree-split.
//
// Result: the reduce call's response.
export async function generateTreeSplit(
model: string,
shards: string[],
map_prompt: (shard: string, scratchpad: string) => string,
reduce_prompt: (scratchpad: string) => string,
opts: {
max_tokens?: number;
temperature?: number;
system?: string;
cloud?: boolean;
on_shard?: (i: number, total: number) => void;
scratchpad_budget?: number;
} = {},
): Promise<{ response: string; scratchpad: string; shards_processed: number }> {
const cloud = opts.cloud ?? false;
const scratchpadBudget = opts.scratchpad_budget ?? 6000;
let scratchpad = "";
for (let i = 0; i < shards.length; i++) {
if (opts.on_shard) opts.on_shard(i + 1, shards.length);
const shardPrompt = map_prompt(shards[i], scratchpad);
// If the per-shard prompt alone exceeds window, the caller sharded
// too coarsely — bubble up rather than silently truncating.
assertContextBudget(model, shardPrompt, {
system: opts.system,
max_tokens: opts.max_tokens,
bypass: false,
});
const shardOut = await generateContinuable(model, shardPrompt, {
max_tokens: opts.max_tokens ?? 800,
temperature: opts.temperature,
system: opts.system,
shape: "text",
cloud,
});
// Append to scratchpad; truncate oldest if over budget.
scratchpad = (scratchpad + `\n— shard ${i + 1}/${shards.length} digest —\n` + shardOut.trim()).slice(-scratchpadBudget * 4);
}
const reducePrompt = reduce_prompt(scratchpad);
assertContextBudget(model, reducePrompt, {
system: opts.system,
max_tokens: opts.max_tokens,
bypass: false,
});
const response = await generateContinuable(model, reducePrompt, {
max_tokens: opts.max_tokens ?? 1500,
temperature: opts.temperature,
system: opts.system,
shape: "text",
cloud,
});
return { response, scratchpad, shards_processed: shards.length };
}
// Known context windows — matches crates/../config/models.json. Kept in
// code as a fallback so the test harness doesn't crash if the config is
// missing. Production path should read from models.json.
@@ -29,10 +237,14 @@ export const CONTEXT_WINDOWS: Record<string, number> = {
"mistral:latest": 32768,
"qwen2.5:latest": 32768,
"qwen3:latest": 40960,
"qwen3.5:latest": 262144, // local 9.7B — new executor
"qwen3-embedding": 32768, // local embedding model
"nomic-embed-text-v2-moe": 2048, // local embedding, MoE
"gpt-oss:20b": 131072,
"gpt-oss:120b": 131072,
"qwen3.5:397b": 131072,
"kimi-k2-thinking": 200000,
"kimi-k2.6": 200000, // cloud — new T4 candidate
"kimi-k2:1t": 1048576,
"deepseek-v3.1:671b": 131072,
"glm-4.7": 131072,
@@ -89,6 +301,7 @@ export interface LogEntry {
| "critique"
| "propose_done"
| "consensus_done"
| "note"
| "error";
content: any;
}
@@ -105,7 +318,11 @@ export type Action =
export interface Fill {
candidate_id: string;
name: string;
reason: string;
reason?: string; // optional — the schema used to require it; nothing
// downstream consumed it, and qwen3.5 would emit
// 100-150 char justifications per fill that blew
// the JSON budget on 4+ fills. Kept optional so
// models that still emit it don't break parsing.
}
// --- HTTP helpers (fail-fast) ---
@@ -135,7 +352,10 @@ export async function callTool(tool: string, args: Record<string, any>): Promise
});
}
export async function hybridSearch(sql_filter: string, question: string, k = 10): Promise<any> {
// Default k=20 is a floor, not a ceiling — executor prompt instructs
// models to scale k to 5× target_count (cap 80) so multi-fill events
// get a meaningfully deep pool to rank within.
export async function hybridSearch(sql_filter: string, question: string, k = 20): Promise<any> {
return http("POST", `${GATEWAY}/vectors/hybrid`, { sql_filter, question, k });
}
@@ -150,6 +370,7 @@ export async function generate(model: string, prompt: string, opts: {
temperature?: number;
system?: string;
bypass_budget?: boolean;
think?: boolean;
} = {}): Promise<string> {
assertContextBudget(model, prompt, {
system: opts.system,
@@ -163,11 +384,13 @@ export async function generate(model: string, prompt: string, opts: {
max_tokens: opts.max_tokens ?? 800,
};
if (opts.system) body.system = opts.system;
if (opts.think !== undefined) body.think = opts.think;
const r = await http<any>("POST", `${SIDECAR}/generate`, body);
const text = r.text ?? "";
if (!text || typeof text !== "string") {
throw new Error(`generate returned empty text from ${model}: ${JSON.stringify(r).slice(0, 200)}`);
}
const text = typeof r.text === "string" ? r.text : "";
// Do NOT throw on empty. Thinking models (gpt-oss, qwen3.5) burn the
// max_tokens budget on hidden reasoning and emit "" when budget was
// too tight. generateContinuable detects empty + continues with more
// budget. Callers that expected non-empty can check themselves.
return text;
}
@@ -181,6 +404,7 @@ export async function generateCloud(model: string, prompt: string, opts: {
temperature?: number;
system?: string;
bypass_budget?: boolean;
think?: boolean;
} = {}): Promise<string> {
if (!OLLAMA_CLOUD_KEY) {
throw new Error("OLLAMA_CLOUD_KEY not set; cannot reach Ollama Cloud");
@@ -200,6 +424,7 @@ export async function generateCloud(model: string, prompt: string, opts: {
},
};
if (opts.system) body.system = opts.system;
if (opts.think !== undefined) body.think = opts.think;
const resp = await fetch(`${OLLAMA_CLOUD_URL}/api/generate`, {
method: "POST",
headers: {
@@ -212,10 +437,9 @@ export async function generateCloud(model: string, prompt: string, opts: {
throw new Error(`Ollama Cloud ${resp.status}: ${await resp.text().catch(() => "?")}`);
}
const data: any = await resp.json();
const text = data.response ?? "";
if (!text) {
throw new Error(`Ollama Cloud returned empty response for ${model}: ${JSON.stringify(data).slice(0, 200)}`);
}
const text = typeof data.response === "string" ? data.response : "";
// Same non-throw policy as local generate() — empty text is a valid
// signal that thinking ate the budget. Let generateContinuable retry.
return text;
}
@@ -231,7 +455,11 @@ Available tools (each takes a JSON "args" object):
{"index_name":"workers_500k_v1",
"sql_filter":"role = 'Forklift Operator' AND city = 'Toledo' AND state = 'OH' AND CAST(availability AS DOUBLE) > 0.5",
"question":"reliable forklift operator Toledo",
"k":10}
"k":40}
→ k should scale with target_count: roughly 5× the number of fills
needed, floor 20, cap 80. For 1-2 fills use k=20. For 5 fills use
k=40. A deep pool lets the ranker discriminate across a larger
candidate set; k=10 was too tight for multi-fill events.
- sql(query: string)
→ Raw read-only SELECT. Use for verification (confirm a worker exists,
@@ -363,8 +591,8 @@ Your next action MUST be a JSON object matching one of these shapes:
— use on turn 1 to outline your approach. Steps must be concrete.
{"kind":"tool_call","tool":"...","args":{...},"rationale":"why"}
— call a tool and see its result next turn.
{"kind":"propose_done","fills":[{"candidate_id":"...","name":"First Last","reason":"why them"}],"rationale":"..."}
— propose you've met the target. fills MUST have EXACTLY ${task.target_count} entries — count twice before emitting.
{"kind":"propose_done","fills":[{"candidate_id":"...","name":"First Last"}],"rationale":"..."}
— propose you've met the target. fills MUST have EXACTLY ${task.target_count} entries — count twice before emitting. Each fill is ONLY {candidate_id, name} — no reason field, no scores, no commentary.
Strategy tip: once "CANDIDATES SURFACED SO FAR" has ≥ ${task.target_count} entries in ${task.target_city}, ${task.target_state} matching ${task.target_role}, verify ONE via the sql tool (to satisfy the reviewer's SQL-verification criterion) and then propose_done with the top ${task.target_count}. Don't keep re-searching.
+210
View File
@@ -0,0 +1,210 @@
// Scenario generator for Phase 22 KB corpus-building.
//
// Emits N unique ScenarioSpec JSON files under
// tests/multi-agent/scenarios/ covering:
// - different clients (so sig varies even when events match)
// - different city/state combos actually present in workers_500k
// - varied event sequences (baseline/recurring/expansion/emergency/misplacement)
// - varied role mixes from the industrial staffing taxonomy
//
// Each scenario spec is written as scen_NN_CLIENT_CITY.json and can be
// fed to scenario.ts as argv[2]. A sibling run_batch.sh runs them all
// sequentially so the KB populates overnight.
//
// Determinism: the RNG seed is argv[2] (defaulting to 42) so repeat
// invocations produce identical specs.
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
// Deterministic PRNG — mulberry32, same as many test harnesses. Stable
// across bun versions; not cryptographic.
function mulberry32(seed: number) {
let s = seed >>> 0;
return () => {
s = (s + 0x6D2B79F5) >>> 0;
let t = s;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// Cities known to exist in workers_500k, chosen to avoid false-empty
// searches. All Midwest because that's the target persona's geography.
const CITIES: Array<{ city: string; state: string }> = [
{ city: "Toledo", state: "OH" },
{ city: "Cleveland", state: "OH" },
{ city: "Columbus", state: "OH" },
{ city: "Cincinnati", state: "OH" },
{ city: "Akron", state: "OH" },
{ city: "Detroit", state: "MI" },
{ city: "Grand Rapids", state: "MI" },
{ city: "Flint", state: "MI" },
{ city: "Indianapolis", state: "IN" },
{ city: "Fort Wayne", state: "IN" },
{ city: "Gary", state: "IN" },
{ city: "Chicago", state: "IL" },
{ city: "Joliet", state: "IL" },
{ city: "Rockford", state: "IL" },
{ city: "Milwaukee", state: "WI" },
{ city: "Madison", state: "WI" },
{ city: "Louisville", state: "KY" },
{ city: "Lexington", state: "KY" },
{ city: "Kansas City", state: "MO" },
{ city: "St. Louis", state: "MO" },
];
// Industrial staffing role taxonomy. Weighted so common roles appear
// more often (realistic distribution).
const ROLES: Array<{ role: string; weight: number }> = [
{ role: "Warehouse Associate", weight: 5 },
{ role: "Machine Operator", weight: 4 },
{ role: "Forklift Operator", weight: 4 },
{ role: "Loader", weight: 3 },
{ role: "Material Handler", weight: 3 },
{ role: "Assembler", weight: 3 },
{ role: "Quality Tech", weight: 2 },
{ role: "Picker", weight: 3 },
{ role: "Packer", weight: 3 },
{ role: "Shipping Clerk", weight: 2 },
{ role: "Receiving Clerk", weight: 2 },
{ role: "Welder", weight: 2 },
{ role: "CNC Operator", weight: 2 },
{ role: "Maintenance Tech", weight: 1 },
];
const CLIENTS = [
"Riverfront Steel", "Northland Logistics", "Great Lakes Mfg",
"Midway Distribution", "Pioneer Assembly", "Cornerstone Fabrication",
"Horizon Supply", "Keystone Plastics", "Apex Warehouse",
"Heritage Foods", "Summit Industrial", "Vanguard Components",
"Centennial Packaging", "Parallel Machining", "Beacon Freight",
];
function pickWeighted<T extends { weight: number }>(rng: () => number, items: T[]): T {
const total = items.reduce((s, x) => s + x.weight, 0);
let r = rng() * total;
for (const x of items) { r -= x.weight; if (r <= 0) return x; }
return items[items.length - 1];
}
function pick<T>(rng: () => number, items: T[]): T {
return items[Math.floor(rng() * items.length)];
}
// Event shape templates. Each scenario picks 3-6 of these at random.
// Multi-fill counts skew low to make the harness quicker; 5+ fill
// events are the hardest and should be rarer in a corpus run.
type EventKind = "baseline_fill" | "recurring" | "expansion" | "emergency" | "misplacement";
function makeEvent(
rng: () => number,
kind: EventKind,
at: string,
city: string,
state: string,
): any {
const { role } = pickWeighted(rng, ROLES);
const count = kind === "misplacement" ? 1
: kind === "expansion" ? 2 + Math.floor(rng() * 4) // 2-5
: kind === "baseline_fill" ? 1 + Math.floor(rng() * 3) // 1-3
: kind === "recurring" ? 1 + Math.floor(rng() * 2) // 1-2
: /* emergency */ 2 + Math.floor(rng() * 3); // 2-4
const hour = 8 + Math.floor(rng() * 10);
const min = Math.random() > 0.5 ? 0 : 30;
const at_real = `${String(hour).padStart(2, "0")}:${String(min).padStart(2, "0")}`;
return {
kind,
at: at_real,
role,
count,
city,
state,
shift_start: `${at_real.replace(":", ":")} AM`,
};
}
function genSpec(rng: () => number, id: number): any {
const client = pick(rng, CLIENTS);
const { city, state } = pick(rng, CITIES);
const today = new Date();
const date = new Date(today.getTime() + id * 86400000)
.toISOString().split("T")[0];
// Scenario shape mix — 60% pure fill (baseline+recurring+expansion),
// 40% mixed (add emergency and/or misplacement).
const includeEmergency = rng() > 0.6;
const includeMisplacement = rng() > 0.6;
const events: any[] = [];
// always at least one baseline
events.push(makeEvent(rng, "baseline_fill", "08:00", city, state));
if (rng() > 0.3) events.push(makeEvent(rng, "recurring", "10:30", city, state));
if (rng() > 0.5) events.push(makeEvent(rng, "expansion", "12:15", city, state));
if (includeEmergency) events.push(makeEvent(rng, "emergency", "14:00", city, state));
if (includeMisplacement) {
const e = makeEvent(rng, "misplacement", "15:45", city, state);
if (events.length > 0) e.replaces_event = events[0].at;
events.push(e);
}
// Contract terms — most real staffing contracts have these. 70% of
// generated specs carry them so KB + T3 learn to reason about budget
// and radius trade-offs, not just geography. Distributions are
// deliberately varied: 2 week to 45 day deadlines, $22-$38/hr caps,
// 25-150mi local radii.
const contract = rng() > 0.3 ? (() => {
const deadlineDays = 10 + Math.floor(rng() * 35);
const deadlineDate = new Date(today.getTime() + (id + deadlineDays) * 86400000)
.toISOString().split("T")[0];
const budgetPerHour = 22 + Math.floor(rng() * 17);
const bonusRadius = 25 + Math.floor(rng() * 125);
const bonusPerHour = 2 + Math.floor(rng() * 5);
const fill: "paramount" | "preferred" = rng() > 0.4 ? "paramount" : "preferred";
return {
deadline: deadlineDate,
budget_per_hour_max: budgetPerHour,
local_bonus_per_hour: bonusPerHour,
local_bonus_radius_mi: bonusRadius,
fill_requirement: fill,
};
})() : undefined;
return { client, date, events, ...(contract ? { contract } : {}) };
}
async function main() {
const n = Number(process.argv[2] ?? 20);
const seed = Number(process.argv[3] ?? 42);
const rng = mulberry32(seed);
const outDir = "tests/multi-agent/scenarios";
await mkdir(outDir, { recursive: true });
const manifest: Array<{ file: string; client: string; city: string; events: number }> = [];
for (let i = 0; i < n; i++) {
const spec = genSpec(rng, i);
const cityLabel = spec.events[0].city.replace(/\s+/g, "_");
const fname = `scen_${String(i).padStart(3, "0")}_${spec.client.replace(/\s+/g, "_")}_${cityLabel}.json`;
await writeFile(join(outDir, fname), JSON.stringify(spec, null, 2));
manifest.push({
file: fname,
client: spec.client,
city: spec.events[0].city,
events: spec.events.length,
});
}
await writeFile(
join(outDir, "manifest.json"),
JSON.stringify({ count: n, seed, scenarios: manifest }, null, 2),
);
console.log(`✓ generated ${n} scenarios → ${outDir}/ (seed=${seed})`);
for (const m of manifest.slice(0, 5)) {
console.log(` ${m.file} — ${m.client} (${m.city}), ${m.events} events`);
}
if (manifest.length > 5) console.log(` ... +${manifest.length - 5} more`);
}
main().catch(e => {
console.error("gen_scenarios failed:", (e as Error).message);
process.exit(1);
});
+124
View File
@@ -0,0 +1,124 @@
// Phase 23 demo — 4 staffer personas × 3 contract scenarios each.
// Same contracts run against different staffers to measure competence
// differential. After the batch, findNeighbors should rank top-staffer
// playbooks above junior-staffer playbooks for similar scenarios.
//
// Output: 12 spec files under tests/multi-agent/scenarios/staffer_demo/
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
// Per-staffer tool_level mirrors the real-world asymmetry: senior gets
// the most powerful stack, trainee gets the least. The architectural
// question is whether the playbook inheritance is strong enough to let
// the trainee still produce usable outcomes when the big tools are off.
const STAFFERS = [
{ id: "S-001", name: "Maria Chen", tenure_months: 48, role: "senior" as const, tool_level: "full" as const },
{ id: "S-002", name: "James Park", tenure_months: 14, role: "mid" as const, tool_level: "local" as const },
{ id: "S-003", name: "Sam Torres", tenure_months: 4, role: "junior" as const, tool_level: "basic" as const },
{ id: "S-004", name: "Alex Rivera", tenure_months: 1, role: "trainee" as const, tool_level: "minimal" as const },
];
// Three contract shapes — one downtown assembly, one warehouse ramp,
// one emergency recovery. Different cities to vary the sig_hash.
const CONTRACTS = [
{
tag: "nashville_downtown",
client: "Riverline Logistics — Nashville Downtown Build-Out",
city: "Nashville", state: "TN",
contract: {
deadline: "2026-05-19",
budget_total_usd: 180000,
budget_per_hour_max: 32,
local_bonus_per_hour: 4,
local_bonus_radius_mi: 75,
fill_requirement: "paramount" as const,
},
events: [
{ kind: "baseline_fill", at: "07:00", role: "Welder", count: 4 },
{ kind: "expansion", at: "08:30", role: "Packaging Operator", count: 6 },
{ kind: "baseline_fill", at: "09:00", role: "Shipping Clerk", count: 2 },
{ kind: "emergency", at: "13:00", role: "Welder", count: 2, deadline: "15:00" },
{ kind: "misplacement", at: "15:30", role: "Packaging Operator", count: 1, replaces_event: "08:30" },
],
},
{
tag: "joliet_warehouse",
client: "Midway Distribution — Joliet DC Ramp",
city: "Joliet", state: "IL",
contract: {
deadline: "2026-05-12",
budget_total_usd: 120000,
budget_per_hour_max: 28,
local_bonus_per_hour: 3,
local_bonus_radius_mi: 50,
fill_requirement: "preferred" as const,
},
events: [
{ kind: "baseline_fill", at: "07:00", role: "Warehouse Associate", count: 5 },
{ kind: "recurring", at: "10:00", role: "Forklift Operator", count: 3 },
{ kind: "expansion", at: "12:30", role: "Picker", count: 4 },
{ kind: "misplacement", at: "15:00", role: "Forklift Operator", count: 1, replaces_event: "10:00" },
],
},
{
tag: "indianapolis_assembly",
client: "Pioneer Assembly — Indianapolis Plant Expansion",
city: "Indianapolis", state: "IN",
contract: {
deadline: "2026-05-26",
budget_total_usd: 220000,
budget_per_hour_max: 30,
local_bonus_per_hour: 5,
local_bonus_radius_mi: 60,
fill_requirement: "paramount" as const,
},
events: [
{ kind: "baseline_fill", at: "07:30", role: "Assembler", count: 6 },
{ kind: "recurring", at: "09:30", role: "Quality Tech", count: 2 },
{ kind: "expansion", at: "11:00", role: "Machine Operator", count: 5 },
{ kind: "emergency", at: "14:00", role: "Machine Operator", count: 3, deadline: "16:00" },
{ kind: "misplacement", at: "16:00", role: "Assembler", count: 1, replaces_event: "07:30" },
],
},
];
async function main() {
const outDir = "tests/multi-agent/scenarios/staffer_demo";
await mkdir(outDir, { recursive: true });
const manifest: Array<{ file: string; staffer: string; contract: string; client: string }> = [];
let day = 0;
for (const staffer of STAFFERS) {
for (const ct of CONTRACTS) {
day += 1;
const date = new Date(Date.now() + day * 86400000).toISOString().split("T")[0];
const spec = {
client: ct.client,
date,
contract: ct.contract,
staffer,
events: ct.events.map(e => ({
...e,
city: ct.city,
state: ct.state,
shift_start: `${e.at} ${e.at.startsWith("0") ? "AM" : "PM"}`,
scenario_note: `Staffed by ${staffer.name} (${staffer.role}, ${staffer.tenure_months}mo). Contract deadline ${ct.contract.deadline}, fill=${ct.contract.fill_requirement}.`,
})),
};
const fname = `${staffer.id}_${ct.tag}.json`;
await writeFile(join(outDir, fname), JSON.stringify(spec, null, 2));
manifest.push({ file: fname, staffer: staffer.name, contract: ct.tag, client: ct.client });
}
}
await writeFile(
join(outDir, "manifest.json"),
JSON.stringify({ count: manifest.length, scenarios: manifest }, null, 2),
);
console.log(`✓ ${manifest.length} staffer-demo specs → ${outDir}/`);
for (const m of manifest) console.log(` ${m.file} — ${m.staffer} × ${m.contract}`);
}
main().catch(e => {
console.error("gen_staffer_demo failed:", (e as Error).message);
process.exit(1);
});
+51
View File
@@ -0,0 +1,51 @@
import { test, expect } from "bun:test";
import { computeSignature, specDigest } from "./kb.ts";
// kb signature invariants — required so the KB's retrieval layer
// doesn't silently drift when we add fields to ScenarioSpec.
test("computeSignature is stable across reorderings of unrelated fields", () => {
const a = {
client: "Acme Corp",
events: [
{ kind: "baseline_fill", role: "Welder", count: 3, city: "Toledo", state: "OH" },
],
};
const b = { ...a, date: "2026-05-01", contract: { deadline: "2026-05-15" } } as any;
const c = { ...a, staffer: { id: "S-1", name: "X", tenure_months: 10, role: "senior" } } as any;
const sigA = computeSignature(a);
const sigB = computeSignature(b);
const sigC = computeSignature(c);
expect(sigA).toBe(sigB);
expect(sigA).toBe(sigC);
});
test("computeSignature changes when role changes", () => {
const base = { client: "Acme", events: [{ kind: "baseline_fill", role: "Welder", count: 3, city: "Toledo", state: "OH" }] };
const swapped = { client: "Acme", events: [{ kind: "baseline_fill", role: "Electrician", count: 3, city: "Toledo", state: "OH" }] };
expect(computeSignature(base)).not.toBe(computeSignature(swapped));
});
test("computeSignature changes when city or count changes", () => {
const base = { client: "A", events: [{ kind: "baseline_fill", role: "Welder", count: 3, city: "Toledo", state: "OH" }] };
const cityChange = { ...base, events: [{ ...base.events[0], city: "Detroit", state: "MI" }] };
const countChange = { ...base, events: [{ ...base.events[0], count: 5 }] };
expect(computeSignature(base)).not.toBe(computeSignature(cityChange));
expect(computeSignature(base)).not.toBe(computeSignature(countChange));
});
test("specDigest includes each event's role + city", () => {
const spec = {
client: "Acme",
events: [
{ kind: "baseline_fill", role: "Welder", count: 3, city: "Toledo", state: "OH" },
{ kind: "emergency", role: "Loader", count: 2, city: "Chicago", state: "IL" },
],
};
const digest = specDigest(spec);
expect(digest).toContain("Acme");
expect(digest).toContain("Welder");
expect(digest).toContain("Toledo,OH");
expect(digest).toContain("Loader");
expect(digest).toContain("Chicago,IL");
});
+600
View File
@@ -0,0 +1,600 @@
// Phase 22 — Internal Knowledge Library.
//
// Sits on top of the successful-playbook store. Tracks which
// configurations produced which outcomes for which playbook
// signatures, detects fail→succeed error corrections across runs,
// and emits pathway recommendations that the NEXT scenario reads
// at startup.
//
// Event-driven cycle (not wall-clock polling):
// scenario ends → kb.indexRun() → kb.recommendFor(nextSpec) → next
// scenario reads top rec and applies.
//
// File layout under data/_kb/:
// signatures.jsonl — (sig_hash, embedding[], first_seen, last_seen, run_count)
// outcomes.jsonl — append-only per-run: {sig, run_id, models, pathway, ok_events, elapsed_s, errors[]}
// config_snapshots.jsonl — config/models.json hash + env at each ingest
// error_corrections.jsonl — fail→succeed deltas
// pathway_recommendations.jsonl — AI-synthesized suggestions
//
// Why file-based: Phase 19 playbook_memory is already in the
// catalogd+vectord stack. This is a separate meta-layer — keeping
// it file-based first lets us iterate quickly without a gateway
// schema migration. Rust port lands once the shape stabilizes
// (mirrors how Phase 21 primitives are TS-first → Rust next sprint).
import { mkdir, readFile, writeFile, appendFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { SIDECAR, generateContinuable } from "./agent.ts";
const KB_DIR = "data/_kb";
const SIGNATURES_FILE = "signatures.jsonl";
const OUTCOMES_FILE = "outcomes.jsonl";
const CONFIG_SNAPSHOTS_FILE = "config_snapshots.jsonl";
const ERROR_CORRECTIONS_FILE = "error_corrections.jsonl";
const RECOMMENDATIONS_FILE = "pathway_recommendations.jsonl";
const STAFFERS_FILE = "staffers.jsonl";
// What a playbook signature looks like — stable hash of the scenario
// shape that ignores timestamps and specific worker IDs. Two runs with
// the same sequence of (kind, role, count, city, state) get the same
// hash. This is the retrieval key for the KB.
export interface PlaybookSignature {
sig_hash: string;
client: string;
events_digest: string; // human-readable event summary
embedding: number[]; // sidecar embed, for nearest-neighbor
first_seen: string;
last_seen: string;
run_count: number;
}
// What we record after a run completes.
export interface RunOutcome {
sig_hash: string;
run_id: string; // scenario dir basename
date: string;
models: {
executor: string;
reviewer: string;
overview: string;
overview_cloud: boolean;
};
staffer?: { // Phase 23 — who ran this scenario
id: string;
name: string;
tenure_months: number;
role: string;
};
ok_events: number;
total_events: number;
total_turns: number;
total_gap_signals: number;
total_citations: number;
rescue_attempts: number; // Phase 22 item B
rescue_successes: number;
per_event: Array<{
at: string;
kind: string;
role: string;
count: number;
ok: boolean;
turns: number;
error: string | null;
}>;
elapsed_secs: number;
created_at: string;
}
// Per-staffer aggregate. Recomputed from outcomes.jsonl after every
// run. Competence_score weights neighbor retrieval so Senior staffers'
// playbooks rank higher for similar scenarios than Junior staffers'.
export interface StafferStats {
id: string;
name: string;
tenure_months: number;
role: string;
total_runs: number;
total_events_attempted: number;
total_events_ok: number;
fill_rate: number; // ok / attempted
avg_turns_per_event: number;
avg_citations_per_run: number;
rescue_attempts: number;
rescue_successes: number;
rescue_rate: number;
competence_score: number; // 0.0 - 1.0, see formula below
last_updated: string;
}
// The AI-synthesized recommendation written back for future runs.
export interface PathwayRecommendation {
sig_hash: string;
generated_at: string;
generated_by_model: string;
confidence: "high" | "medium" | "low";
rationale: string; // prose from the recommender
top_models: { // suggested tier assignments
executor?: string;
reviewer?: string;
overview?: string;
};
budget_hints: { // suggested max_tokens / think flags
executor_max_tokens?: number;
reviewer_max_tokens?: number;
executor_think?: boolean;
};
pathway_notes: string; // "pre-fetch cert data", "use buffer of 3+"
neighbors_consulted: string[]; // sig_hashes of the playbooks that shaped this rec
}
// Compute a stable hash from the scenario spec. Two runs with the same
// events list (ignoring SMS drafts / timestamps / exclude lists) get
// the same hash.
export function computeSignature(spec: {
client: string;
events: Array<{ kind: string; role: string; count: number; city: string; state: string }>;
}): string {
const canonical = JSON.stringify({
client: spec.client,
events: spec.events.map(e => ({
kind: e.kind, role: e.role, count: e.count, city: e.city, state: e.state,
})),
});
return createHash("sha256").update(canonical).digest("hex").slice(0, 16);
}
// Human-readable digest of the spec, used in recommender prompts.
export function specDigest(spec: {
client: string;
events: Array<{ kind: string; role: string; count: number; city: string; state: string }>;
}): string {
return `${spec.client}: ` + spec.events.map(e =>
`${e.kind}/${e.role}×${e.count} in ${e.city},${e.state}`
).join(" | ");
}
async function ensureKb(): Promise<void> {
await mkdir(KB_DIR, { recursive: true });
}
async function readJsonl<T>(file: string): Promise<T[]> {
try {
const raw = await readFile(join(KB_DIR, file), "utf8");
return raw.split("\n").filter(l => l.trim()).map(l => JSON.parse(l) as T);
} catch {
return [];
}
}
// Index a completed scenario run. Extracts signature, computes
// embedding, appends outcome, updates or inserts signature entry.
export async function indexRun(
scenarioDir: string,
spec: { client: string; date: string; events: Array<any>; staffer?: { id: string; name: string; tenure_months: number; role: string } },
models: { executor: string; reviewer: string; overview: string; overview_cloud: boolean },
elapsed_secs: number,
): Promise<{ sig_hash: string; outcome: RunOutcome }> {
await ensureKb();
const sig_hash = computeSignature(spec);
const digest = specDigest(spec);
// Read results.json produced by scenario.ts to build the outcome record.
const resultsRaw = await readFile(join(scenarioDir, "results.json"), "utf8");
const results = JSON.parse(resultsRaw) as any[];
const rescue_attempts = results.filter(r => r.retry_remediation).length;
const rescue_successes = results.filter(r => r.retry_remediation && r.ok).length;
const outcome: RunOutcome = {
sig_hash,
run_id: scenarioDir.split("/").pop() ?? scenarioDir,
date: spec.date,
models,
staffer: spec.staffer,
ok_events: results.filter(r => r.ok).length,
total_events: results.length,
total_turns: results.reduce((s, r) => s + (r.turns ?? 0), 0),
total_gap_signals: results.reduce((s, r) => s + (r.gap_signals?.length ?? 0), 0),
total_citations: results.reduce((s, r) => s + (r.playbook_citations?.length ?? 0), 0),
rescue_attempts,
rescue_successes,
per_event: results.map(r => ({
at: r.event.at,
kind: r.event.kind,
role: r.event.role,
count: r.event.count,
ok: r.ok,
turns: r.turns ?? 0,
error: r.error ?? null,
})),
elapsed_secs,
created_at: new Date().toISOString(),
};
await appendFile(join(KB_DIR, OUTCOMES_FILE), JSON.stringify(outcome) + "\n");
// Phase 23 — recompute this staffer's aggregate + competence_score.
// Cheap until we have thousands of runs; swap to streaming aggregate
// when that becomes a concern.
if (spec.staffer) {
await recomputeStafferStats(spec.staffer.id);
}
// Embed + upsert signature. Skip if embedding fails — the outcome is
// still persisted, just without neighbor-search hookup.
try {
const resp = await fetch(`${SIDECAR}/embed`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ texts: [digest] }),
});
if (resp.ok) {
const data: any = await resp.json();
const embedding: number[] = data.embeddings?.[0] ?? [];
const existing = await readJsonl<PlaybookSignature>(SIGNATURES_FILE);
const now = new Date().toISOString();
const found = existing.find(s => s.sig_hash === sig_hash);
if (found) {
found.last_seen = now;
found.run_count += 1;
} else {
existing.push({
sig_hash,
client: spec.client,
events_digest: digest,
embedding,
first_seen: now,
last_seen: now,
run_count: 1,
});
}
// Rewrite the whole file — cheap while KB is small; swap to
// append-with-periodic-compact when row count exceeds ~10k.
await writeFile(
join(KB_DIR, SIGNATURES_FILE),
existing.map(s => JSON.stringify(s)).join("\n") + "\n",
);
}
} catch {
// Signature indexing failed — outcome is still captured. Next
// indexRun retries.
}
return { sig_hash, outcome };
}
// Phase 23 — per-staffer aggregate, recomputed from scratch on each
// run. Writes the full table to STAFFERS_FILE (not append-only; we
// always want the current-state snapshot). Competence score is bounded
// 0..1 and combines four dimensions:
// fill_rate — how often the staffer completes their events
// turn_efficiency — lower turn counts are better
// citation_density — signals use of playbook_memory feedback
// rescue_rate — cloud rescue successes per attempt (doesn't
// penalize staffers who never needed one)
// Weights: fill 0.45, turn_eff 0.20, citation 0.20, rescue 0.15.
// Rationale: completing the job matters most; everything else is style.
export async function recomputeStafferStats(staffer_id: string): Promise<StafferStats | null> {
const outcomes = await readJsonl<RunOutcome>(OUTCOMES_FILE);
const mine = outcomes.filter(o => o.staffer?.id === staffer_id);
if (mine.length === 0) return null;
const latest = mine[mine.length - 1].staffer!;
const total_events_attempted = mine.reduce((s, o) => s + o.total_events, 0);
const total_events_ok = mine.reduce((s, o) => s + o.ok_events, 0);
const total_turns = mine.reduce((s, o) => s + o.total_turns, 0);
const total_citations = mine.reduce((s, o) => s + o.total_citations, 0);
const rescue_attempts = mine.reduce((s, o) => s + (o.rescue_attempts ?? 0), 0);
const rescue_successes = mine.reduce((s, o) => s + (o.rescue_successes ?? 0), 0);
const fill_rate = total_events_attempted > 0 ? total_events_ok / total_events_attempted : 0;
const avg_turns_per_event = total_events_attempted > 0 ? total_turns / total_events_attempted : 0;
const avg_citations_per_run = mine.length > 0 ? total_citations / mine.length : 0;
const rescue_rate = rescue_attempts > 0 ? rescue_successes / rescue_attempts : 1.0; // never-rescued defaults to perfect
// Normalize each axis to 0..1. Caps chosen from observed distributions.
const turn_eff = avg_turns_per_event === 0 ? 0 : Math.max(0, 1 - Math.min(avg_turns_per_event / 10, 1));
const cite_norm = Math.min(avg_citations_per_run / 3, 1);
const competence_score = 0.45 * fill_rate
+ 0.20 * turn_eff
+ 0.20 * cite_norm
+ 0.15 * rescue_rate;
const stats: StafferStats = {
id: staffer_id,
name: latest.name,
tenure_months: latest.tenure_months,
role: latest.role,
total_runs: mine.length,
total_events_attempted,
total_events_ok,
fill_rate,
avg_turns_per_event,
avg_citations_per_run,
rescue_attempts,
rescue_successes,
rescue_rate,
competence_score,
last_updated: new Date().toISOString(),
};
// Rewrite whole file — cheap at O(staffers) scale.
const existing = await readJsonl<StafferStats>(STAFFERS_FILE);
const others = existing.filter(s => s.id !== staffer_id);
others.push(stats);
await writeFile(
join(KB_DIR, STAFFERS_FILE),
others.map(s => JSON.stringify(s)).join("\n") + "\n",
);
return stats;
}
// Public accessor — used by findNeighbors + the staffer report script.
export async function loadStafferStats(): Promise<StafferStats[]> {
return readJsonl<StafferStats>(STAFFERS_FILE);
}
// Cosine similarity for neighbor lookup. Float32-in-memory is fine for
// O(thousands) signatures; swap to vectord HNSW once the corpus grows.
function cosine(a: number[], b: number[]): number {
if (a.length === 0 || a.length !== b.length) return 0;
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
return dot / (Math.sqrt(na) * Math.sqrt(nb) + 1e-12);
}
// Find the k nearest-neighbor signatures for a target spec, returning
// neighbor sigs + their outcome history. This is what the recommender
// feeds to the overview model.
//
// Phase 23 — weighted by staffer competence. The ranking score is
// cosine_similarity * max_competence_among_this_sig's_outcomes. Top
// staffers' playbooks surface first even if their scenario was
// slightly less similar than a junior's. Sigs with no staffer history
// fall back to raw similarity (competence defaults to 0.5).
export async function findNeighbors(spec: any, k = 5): Promise<Array<{
sig: PlaybookSignature;
similarity: number;
weighted_score: number;
best_staffer_competence: number;
best_staffer_id: string | null;
outcomes: RunOutcome[];
}>> {
await ensureKb();
const digest = specDigest(spec);
const targetHash = computeSignature(spec);
const resp = await fetch(`${SIDECAR}/embed`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ texts: [digest] }),
});
if (!resp.ok) return [];
const data: any = await resp.json();
const targetVec: number[] = data.embeddings?.[0] ?? [];
const sigs = await readJsonl<PlaybookSignature>(SIGNATURES_FILE);
const outcomes = await readJsonl<RunOutcome>(OUTCOMES_FILE);
const staffers = await readJsonl<StafferStats>(STAFFERS_FILE);
const competenceById = new Map(staffers.map(s => [s.id, s.competence_score]));
// Per-sig best staffer — the highest-competence coordinator who has
// ever run this signature. Used to weight the sig's ranking.
function bestStafferFor(sig_hash: string): { id: string | null; competence: number } {
const mine = outcomes.filter(o => o.sig_hash === sig_hash && o.staffer?.id);
if (mine.length === 0) return { id: null, competence: 0.5 };
let bestId: string | null = null;
let bestComp = 0;
for (const o of mine) {
const c = competenceById.get(o.staffer!.id) ?? 0.5;
if (c > bestComp) { bestComp = c; bestId = o.staffer!.id; }
}
return { id: bestId, competence: bestComp };
}
const ranked = sigs
.filter(s => s.sig_hash !== targetHash) // don't recommend against self
.map(s => {
const similarity = cosine(targetVec, s.embedding);
const best = bestStafferFor(s.sig_hash);
// Weighted score: similarity multiplied by the best staffer's
// competence (floored at 0.3 so a sig with a low-competence
// staffer still shows up if similarity is very high).
const weight = Math.max(best.competence, 0.3);
return {
sig: s,
similarity,
weighted_score: similarity * weight,
best_staffer_competence: best.competence,
best_staffer_id: best.id,
};
})
.sort((a, b) => b.weighted_score - a.weighted_score)
.slice(0, k);
return ranked.map(r => ({
...r,
outcomes: outcomes.filter(o => o.sig_hash === r.sig.sig_hash),
}));
}
// Detect fail→succeed pairs within outcomes for the same signature.
// Diff the config/models between them; that's the correction.
export async function detectErrorCorrections(): Promise<Array<{
sig_hash: string;
before: RunOutcome;
after: RunOutcome;
models_changed: Record<string, { from: string; to: string }>;
}>> {
const outcomes = await readJsonl<RunOutcome>(OUTCOMES_FILE);
const bySig = new Map<string, RunOutcome[]>();
for (const o of outcomes) {
const arr = bySig.get(o.sig_hash) ?? [];
arr.push(o);
bySig.set(o.sig_hash, arr);
}
const corrections: any[] = [];
for (const [sig, runs] of bySig) {
runs.sort((a, b) => a.created_at.localeCompare(b.created_at));
for (let i = 1; i < runs.length; i++) {
const prev = runs[i - 1];
const cur = runs[i];
if (prev.ok_events < prev.total_events && cur.ok_events > prev.ok_events) {
const changed: Record<string, { from: string; to: string }> = {};
for (const k of ["executor", "reviewer", "overview"] as const) {
if (prev.models[k] !== cur.models[k]) {
changed[k] = { from: prev.models[k], to: cur.models[k] };
}
}
corrections.push({ sig_hash: sig, before: prev, after: cur, models_changed: changed });
}
}
}
return corrections;
}
// Generate a pathway recommendation for a target spec. Reads k-NN
// signatures + their outcome history, asks an overview model to
// synthesize "best path forward", writes to recommendations file.
export async function recommendFor(
spec: any,
opts: { overview_model?: string; cloud?: boolean; k?: number } = {},
): Promise<PathwayRecommendation | null> {
await ensureKb();
const k = opts.k ?? 5;
const overviewModel = opts.overview_model ?? "gpt-oss:20b";
const cloud = opts.cloud ?? false;
const neighbors = await findNeighbors(spec, k);
const corrections = await detectErrorCorrections();
const targetHash = computeSignature(spec);
if (neighbors.length === 0) {
// Cold start — no history. Write a minimal rec so next run still
// gets "we looked" instead of a missing file.
const rec: PathwayRecommendation = {
sig_hash: targetHash,
generated_at: new Date().toISOString(),
generated_by_model: overviewModel,
confidence: "low",
rationale: "No prior runs in KB — first time this signature is seen. Use default model matrix.",
top_models: {},
budget_hints: {},
pathway_notes: "No neighbor history to draw on. Proceed with current config; KB will have data after this run.",
neighbors_consulted: [],
};
await appendFile(join(KB_DIR, RECOMMENDATIONS_FILE), JSON.stringify(rec) + "\n");
return rec;
}
// Build the prompt. Include target spec digest, neighbor digests with
// their outcome stats, and recent error corrections. Ask for
// structured output so we can parse it.
const staffers = await readJsonl<StafferStats>(STAFFERS_FILE);
const stafferById = new Map(staffers.map(s => [s.id, s]));
const neighborBlock = neighbors.map(n => {
const best = n.outcomes.reduce((a, b) =>
a && a.ok_events / Math.max(1, a.total_events) >= b.ok_events / Math.max(1, b.total_events) ? a : b,
n.outcomes[0]);
const avgOk = n.outcomes.length > 0
? (n.outcomes.reduce((s, o) => s + o.ok_events, 0) / n.outcomes.length).toFixed(1)
: "?";
const topStaffer = n.best_staffer_id ? stafferById.get(n.best_staffer_id) : null;
const stafferTag = topStaffer
? ` [top staffer: ${topStaffer.name} (${topStaffer.role}, competence=${topStaffer.competence_score.toFixed(2)})]`
: "";
return `- sig ${n.sig.sig_hash} sim=${n.similarity.toFixed(3)} weighted=${n.weighted_score.toFixed(3)}${stafferTag}: ${n.outcomes.length} runs, avg ${avgOk}/${best?.total_events ?? "?"} ok; best models: exec=${best?.models.executor ?? "?"} review=${best?.models.reviewer ?? "?"}`;
}).join("\n");
const correctionBlock = corrections.slice(-3).map(c =>
`- sig ${c.sig_hash}: ${c.before.ok_events}/${c.before.total_events} → ${c.after.ok_events}/${c.after.total_events}; changed=${JSON.stringify(c.models_changed)}`
).join("\n") || "(no corrections observed yet)";
const prompt = `You are the pathway recommender for a staffing coordinator agent system. A new scenario is about to run. Your job: use history of similar runs to recommend the best configuration.
TARGET SCENARIO:
${specDigest(spec)}
${k} NEAREST-NEIGHBOR SIGNATURES FROM HISTORY (by cosine similarity):
${neighborBlock}
RECENT ERROR CORRECTIONS (fail → succeed deltas on same signature):
${correctionBlock}
Your output MUST be a valid JSON object with this shape (and nothing else — no prose before or after):
{
"confidence": "high" | "medium" | "low",
"rationale": "2-3 sentence explanation of what pattern you saw",
"top_models": {"executor": "...", "reviewer": "...", "overview": "..."},
"budget_hints": {"executor_max_tokens": 800, "reviewer_max_tokens": 600, "executor_think": false},
"pathway_notes": "concrete pre-run advice — what to pre-fetch, what to avoid, buffer sizes"
}
Respond with ONLY the JSON object.`;
let raw = "";
try {
raw = await generateContinuable(overviewModel, prompt, {
max_tokens: 1200,
shape: "json",
cloud,
max_continuations: 2,
});
} catch (e) {
return null;
}
let parsed: any;
try {
const m = raw.match(/\{[\s\S]*\}/);
parsed = m ? JSON.parse(m[0]) : null;
} catch {
parsed = null;
}
if (!parsed) return null;
const rec: PathwayRecommendation = {
sig_hash: targetHash,
generated_at: new Date().toISOString(),
generated_by_model: overviewModel,
confidence: parsed.confidence ?? "low",
rationale: String(parsed.rationale ?? "").slice(0, 1000),
top_models: parsed.top_models ?? {},
budget_hints: parsed.budget_hints ?? {},
pathway_notes: String(parsed.pathway_notes ?? "").slice(0, 2000),
neighbors_consulted: neighbors.map(n => n.sig.sig_hash),
};
await appendFile(join(KB_DIR, RECOMMENDATIONS_FILE), JSON.stringify(rec) + "\n");
return rec;
}
// Read-back at scenario start: find the newest recommendation for this
// exact sig_hash (or nearest neighbor of it). Returns null if nothing
// applicable found.
export async function loadRecommendation(spec: any): Promise<PathwayRecommendation | null> {
await ensureKb();
const targetHash = computeSignature(spec);
const recs = await readJsonl<PathwayRecommendation>(RECOMMENDATIONS_FILE);
const matching = recs.filter(r => r.sig_hash === targetHash);
if (matching.length === 0) return null;
matching.sort((a, b) => b.generated_at.localeCompare(a.generated_at));
return matching[0];
}
// Record a config snapshot. Called whenever models.json or the active
// model assignments change, so error-correction diffs have history to
// reference beyond the outcomes file.
export async function snapshotConfig(
models_json_hash: string,
active_models: Record<string, string>,
why: string,
): Promise<void> {
await ensureKb();
await appendFile(
join(KB_DIR, CONFIG_SNAPSHOTS_FILE),
JSON.stringify({
at: new Date().toISOString(),
models_json_hash,
active_models,
why,
}) + "\n",
);
}
+239
View File
@@ -0,0 +1,239 @@
// Unified memory query — one surface that takes any input, normalizes
// it, and returns every memory signal the system has: playbook workers,
// KB pathway recommendations, prior lessons, staffer competence stats,
// and cross-staffer discovered patterns. This is the "seamlessly with
// whatever input" answer J framed it as.
//
// Why a unified gateway instead of separate calls: the memory surfaces
// are semantically related (playbook_memory workers reference runs the
// KB tracks; prior_lessons reference staffers the stats rank). Callers
// shouldn't have to know the topology.
import { readFile, readdir } from "node:fs/promises";
import { join } from "node:path";
import { normalizeInput, type NormalizedInput } from "./normalize.ts";
import { findNeighbors, loadRecommendation, loadStafferStats, computeSignature, type StafferStats, type PathwayRecommendation } from "./kb.ts";
import { GATEWAY, SIDECAR } from "./agent.ts";
export interface MemoryQueryResult {
input: NormalizedInput;
playbook_workers: Array<{
doc_id: string;
name: string;
score: number;
playbook_boost: number;
playbook_citations: string[];
}>;
pathway_recommendation: PathwayRecommendation | null;
neighbor_signatures: Array<{
sig_hash: string;
events_digest: string;
similarity: number;
weighted_score: number;
best_staffer_id: string | null;
}>;
prior_lessons: Array<{
date: string;
client: string;
cities: string;
lesson: string;
}>;
top_staffers: StafferStats[];
discovered_patterns: {
sig_hash: string;
top_workers: Array<{ name: string; endorsements: number }>;
} | null;
latency_ms: {
normalize: number;
playbook_search: number;
kb_neighbors: number;
staffer_stats: number;
prior_lessons: number;
total: number;
};
}
// Main entry. Normalizes the input, then fans out to every memory
// source in parallel. Each source is best-effort: a down dependency
// just leaves its field empty rather than breaking the query.
export async function queryMemory(raw: unknown): Promise<MemoryQueryResult> {
const t0 = Date.now();
// Normalize input first — sets the tone for every downstream call.
const tNorm = Date.now();
const input = await normalizeInput(raw);
const normalize_ms = Date.now() - tNorm;
// Synthesize a minimal scenario spec from normalized input for sig
// computation + KB lookup. Only needed if we have at least role + city.
const pseudoSpec = (input.role && input.city && input.state) ? {
client: input.client ?? "(unknown)",
events: [{
kind: input.intent === "rescue" ? "misplacement" : "baseline_fill",
role: input.role,
count: input.count ?? 1,
city: input.city,
state: input.state,
}],
} : null;
// Fire everything that CAN fire in parallel.
const tPb = Date.now();
const playbookPromise: Promise<MemoryQueryResult["playbook_workers"]> =
(input.role && input.city && input.state)
? fetch(`${GATEWAY}/vectors/hybrid`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
index_name: "workers_500k_v1",
sql_filter: `role = '${input.role.replace(/'/g, "''")}' AND city = '${input.city.replace(/'/g, "''")}' AND state = '${input.state}' AND CAST(availability AS DOUBLE) > 0.5`,
question: `${input.role} ${input.city} ${input.state}`,
top_k: 10,
generate: false,
use_playbook_memory: true,
playbook_memory_k: 100,
}),
}).then(r => r.ok ? r.json() : { sources: [] })
.then((d: any) => (d.sources ?? []).map((s: any) => ({
doc_id: s.doc_id,
name: s.chunk_text?.split("—")[0]?.trim() ?? "?",
score: s.score ?? 0,
playbook_boost: s.playbook_boost ?? 0,
playbook_citations: s.playbook_citations ?? [],
})))
.catch(() => [])
: Promise.resolve([]);
const tKb = Date.now();
const recPromise = pseudoSpec
? loadRecommendation(pseudoSpec).catch(() => null)
: Promise.resolve(null);
const neighborsPromise = pseudoSpec
? findNeighbors(pseudoSpec, 5).catch(() => [])
: Promise.resolve([]);
const tLess = Date.now();
const lessonsPromise: Promise<MemoryQueryResult["prior_lessons"]> =
(input.city && input.state)
? loadRelevantLessons(input.city, input.state).catch(() => [])
: Promise.resolve([]);
const tStaff = Date.now();
const staffersPromise = loadStafferStats().catch(() => []);
// Parallel await
const [playbook_workers, pathway_recommendation, neighbors_raw, prior_lessons, staffers] =
await Promise.all([playbookPromise, recPromise, neighborsPromise, lessonsPromise, staffersPromise]);
// Derived: top-k staffers by competence, from recent activity.
const top_staffers = staffers
.filter(s => s.total_runs > 0)
.sort((a, b) => b.competence_score - a.competence_score)
.slice(0, 5);
// Derived: discovered patterns for this sig (workers endorsed ≥ 2 staffers).
let discovered_patterns: MemoryQueryResult["discovered_patterns"] = null;
if (pseudoSpec) {
const sig_hash = computeSignature(pseudoSpec);
const workers = await collectTopEndorsedWorkers(sig_hash, input.role, input.city, input.state).catch(() => []);
if (workers.length > 0) discovered_patterns = { sig_hash, top_workers: workers };
}
return {
input,
playbook_workers,
pathway_recommendation,
neighbor_signatures: neighbors_raw.map(n => ({
sig_hash: n.sig.sig_hash,
events_digest: n.sig.events_digest,
similarity: n.similarity,
weighted_score: n.weighted_score,
best_staffer_id: n.best_staffer_id,
})),
prior_lessons,
top_staffers,
discovered_patterns,
latency_ms: {
normalize: normalize_ms,
playbook_search: Date.now() - tPb,
kb_neighbors: Date.now() - tKb,
staffer_stats: Date.now() - tStaff,
prior_lessons: Date.now() - tLess,
total: Date.now() - t0,
},
};
}
async function loadRelevantLessons(city: string, state: string | null): Promise<MemoryQueryResult["prior_lessons"]> {
try {
const dir = join("data", "_playbook_lessons");
const files = await readdir(dir).catch(() => [] as string[]);
const out: MemoryQueryResult["prior_lessons"] = [];
for (const f of files) {
if (!f.endsWith(".json")) continue;
try {
const raw = await readFile(join(dir, f), "utf8");
const rec = JSON.parse(raw);
const lessonCities = (rec.cities ?? "").split(",");
const lessonStates = (rec.states ?? "").split(",");
if (lessonCities.includes(city) || (state && lessonStates.includes(state))) {
out.push({
date: rec.date,
client: rec.client,
cities: rec.cities,
lesson: rec.lesson.slice(0, 500),
});
}
} catch { /* skip malformed */ }
}
out.sort((a, b) => (b.date ?? "").localeCompare(a.date ?? ""));
return out.slice(0, 3);
} catch {
return [];
}
}
async function collectTopEndorsedWorkers(
sig_hash: string,
role: string | null,
city: string | null,
state: string | null,
): Promise<Array<{ name: string; endorsements: number }>> {
if (!role || !city || !state) return [];
// Pull from playbook_memory — each successful fill was endorsed;
// count by (name, role, city, state) across past playbooks.
try {
const resp = await fetch(`${GATEWAY}/query/sql`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sql: `SELECT operation, result FROM successful_playbooks_live
WHERE operation LIKE 'fill: ${role.replace(/'/g, "''")} %'
AND operation LIKE '%in ${city.replace(/'/g, "''")}, ${state}%'
ORDER BY timestamp DESC LIMIT 50`,
}),
});
if (!resp.ok) return [];
const data: any = await resp.json();
// successful_playbooks_live.result has the shape
// "N/N filled → Name A, Name B, Name C"
// or historical "Name A | Name B | Name C". Handle both.
const counts = new Map<string, number>();
for (const row of data.rows ?? []) {
const raw = String(row.result ?? "");
const afterArrow = raw.includes("→") ? raw.split("→")[1] : raw;
const names = afterArrow
.split(/[,|]/)
.map((n: string) => n.trim())
.filter(n => n && !/^\d+\/\d+/.test(n) && !n.toLowerCase().startsWith("filled"));
for (const n of names) counts.set(n, (counts.get(n) ?? 0) + 1);
}
return [...counts.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(([name, endorsements]) => ({ name, endorsements }));
} catch {
return [];
}
}
+4 -4
View File
@@ -49,8 +49,8 @@ import {
callTool,
} from "./agent.ts";
const EXECUTOR_MODEL = "mistral:latest";
const REVIEWER_MODEL = "qwen2.5:latest";
const EXECUTOR_MODEL = "qwen3.5:latest";
const REVIEWER_MODEL = "qwen3:latest";
const VERIFIER_MODEL = "qwen2.5:latest";
const PROFILE_ID = "staffing-recruiter";
const INDEX_NAME = "workers_500k_v1";
@@ -175,7 +175,7 @@ async function buildPhase(task: TaskSpec, prefix: string): Promise<BuildResult>
while (turn < MAX_TURNS && !sealed) {
turn += 1;
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 600 });
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 1200 });
const execAction = parseAction(execRaw, "executor");
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: execAction.kind as any, content: execAction });
@@ -192,7 +192,7 @@ async function buildPhase(task: TaskSpec, prefix: string): Promise<BuildResult>
}
}
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 400 });
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 1000 });
const revAction = parseAction(revRaw, "reviewer");
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "critique", content: revAction });
+77
View File
@@ -0,0 +1,77 @@
import { test, expect } from "bun:test";
import { normalizeInputSync, normalizeInput } from "./normalize.ts";
test("structured FillEvent → fast path", () => {
const n = normalizeInputSync({
kind: "baseline_fill", role: "Welder", city: "Nashville",
state: "TN", count: 4,
});
expect(n.extraction_method).toBe("structured");
expect(n.role).toBe("Welder");
expect(n.city).toBe("Nashville");
expect(n.state).toBe("TN");
expect(n.count).toBe(4);
expect(n.confidence).toBe("high");
expect(n.missing_fields.length).toBe(0);
});
test("misplacement kind → rescue intent", () => {
const n = normalizeInputSync({ kind: "misplacement", role: "Welder", city: "Nashville", state: "TN", count: 1 });
expect(n.intent).toBe("rescue");
});
test("structured with full state name normalizes to abbrev", () => {
const n = normalizeInputSync({ role: "Welder", city: "Nashville", state: "Tennessee" });
expect(n.state).toBe("TN");
});
test("natural-language regex path extracts count + role + city + state", () => {
const n = normalizeInputSync("I need 3 Welders in Nashville, TN for next week");
expect(n.role).toBe("Welder");
expect(n.city).toBe("Nashville");
expect(n.state).toBe("TN");
expect(n.count).toBe(3);
expect(n.intent).toBe("fill");
expect(n.extraction_method).toBe("regex");
});
test("natural language with plural role + full state name", () => {
const n = normalizeInputSync("fill 5 Forklift Operators in Chicago, Illinois");
expect(n.role).toBe("Forklift Operator");
expect(n.city).toBe("Chicago");
expect(n.state).toBe("IL");
expect(n.count).toBe(5);
});
test("rescue intent keyword beats fill", () => {
const n = normalizeInputSync("we need to rescue the Nashville welder fill, 2 workers");
expect(n.intent).toBe("rescue");
expect(n.count).toBe(2);
expect(n.role).toBe("Welder");
});
test("partial input reports missing fields with confidence=low or medium", () => {
const n = normalizeInputSync("need welders");
expect(n.role).toBe("Welder");
expect(n.city).toBeNull();
expect(n.state).toBeNull();
expect(n.count).toBeNull();
expect(n.missing_fields).toContain("city");
expect(n.missing_fields).toContain("state");
expect(n.confidence).toBe("low");
});
test("empty object does NOT go through structured path (no role or city)", () => {
const n = normalizeInputSync({});
expect(n.extraction_method).toBe("regex");
});
test("async normalizeInput on clean structured input matches sync", async () => {
const input = { role: "Welder", city: "Nashville", state: "TN", count: 3 };
const sync = normalizeInputSync(input);
const async_ = await normalizeInput(input);
expect(async_.role).toBe(sync.role);
expect(async_.city).toBe(sync.city);
expect(async_.state).toBe(sync.state);
expect(async_.count).toBe(sync.count);
});
+296
View File
@@ -0,0 +1,296 @@
// Input normalizer — accepts whatever shape a caller provides (clean
// structured spec, partial JSON, natural language, a mix) and produces
// a canonical shape the rest of the pipeline understands. This is the
// missing piece that lets KB + playbook_memory + overseer respond
// "seamlessly with whatever input they're given" as J framed it.
//
// The pipeline already works when input is `{role, city, state, count}`;
// what breaks today is "I need three welders in Nashville Monday" —
// the SQL-filter extractors return None, the boost pipeline sees no
// role/geo signal, and retrieval silently degrades to raw cosine.
//
// Strategy: fast path for structured input, LLM fallback only for
// natural language. We don't want to burn an LLM call on "role=Welder
// city=Nashville state=TN count=3" — that's wasteful.
import { generateContinuable } from "./agent.ts";
export interface NormalizedInput {
role: string | null;
city: string | null;
state: string | null; // two-letter
count: number | null;
client: string | null;
deadline: string | null; // ISO date or null
intent: "fill" | "lookup" | "rescue" | "rebuild" | "unknown";
raw_text: string; // what the caller originally sent
confidence: "high" | "medium" | "low";
extraction_method: "structured" | "regex" | "llm" | "mixed";
missing_fields: string[]; // what normalizer couldn't determine
}
// US state abbreviations for regex city,state extraction.
const STATE_ABBRS = new Set([
"AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL","IN","IA",
"KS","KY","LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ",
"NM","NY","NC","ND","OH","OK","OR","PA","RI","SC","SD","TN","TX","UT","VT",
"VA","WA","WV","WI","WY","DC",
]);
const STATE_NAMES_TO_ABBR: Record<string, string> = {
alabama: "AL", alaska: "AK", arizona: "AZ", arkansas: "AR", california: "CA",
colorado: "CO", connecticut: "CT", delaware: "DE", florida: "FL", georgia: "GA",
hawaii: "HI", idaho: "ID", illinois: "IL", indiana: "IN", iowa: "IA",
kansas: "KS", kentucky: "KY", louisiana: "LA", maine: "ME", maryland: "MD",
massachusetts: "MA", michigan: "MI", minnesota: "MN", mississippi: "MS", missouri: "MO",
montana: "MT", nebraska: "NE", nevada: "NV", "new hampshire": "NH", "new jersey": "NJ",
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND",
ohio: "OH", oklahoma: "OK", oregon: "OR", pennsylvania: "PA", "rhode island": "RI",
"south carolina": "SC", "south dakota": "SD", tennessee: "TN", texas: "TX", utah: "UT",
vermont: "VT", virginia: "VA", washington: "WA", "west virginia": "WV", wisconsin: "WI",
wyoming: "WY",
};
// Canonical staffing roles — the normalizer recognizes these regardless
// of casing / plurality. Extend as domains grow.
const KNOWN_ROLES = [
"Warehouse Associate", "Machine Operator", "Forklift Operator", "Loader",
"Material Handler", "Assembler", "Quality Tech", "Picker", "Packer",
"Shipping Clerk", "Receiving Clerk", "Welder", "CNC Operator",
"Maintenance Tech", "Electrician", "Tool & Die Maker", "Safety Coordinator",
"Logistics Coordinator", "Packaging Operator", "Sanitation Worker",
"Line Lead",
];
// Intent keyword hints. Check in order — "rescue" beats generic "fill"
// for "rescue the Nashville welder fill" kind of input.
const INTENT_HINTS: Array<{ re: RegExp; intent: NormalizedInput["intent"] }> = [
{ re: /\brescue|pivot|retry|remediat/i, intent: "rescue" },
{ re: /\brebuild|reindex|re-?embed/i, intent: "rebuild" },
{ re: /\blookup|find|show me|who is|what is/i, intent: "lookup" },
{ re: /\bfill|hire|staff|need|schedule|book/i, intent: "fill" },
];
// Fast structured path — the input is already a FillEvent-shaped JSON
// or a close cousin. Returns null to fall through to regex/LLM.
function fromStructured(raw: any): NormalizedInput | null {
if (!raw || typeof raw !== "object") return null;
const role = typeof raw.role === "string" ? raw.role : null;
const city = typeof raw.city === "string" ? raw.city : null;
const stateRaw = typeof raw.state === "string" ? raw.state : null;
const count = typeof raw.count === "number" ? raw.count : null;
// Only treat as structured if AT LEAST role OR city is present; a
// bare {kind: "baseline_fill"} shouldn't go through this path.
if (!role && !city) return null;
return {
role,
city,
state: normalizeState(stateRaw),
count,
client: typeof raw.client === "string" ? raw.client : null,
deadline: typeof raw.deadline === "string" ? raw.deadline : null,
intent: (raw.kind === "misplacement" || /rescue/i.test(raw.kind ?? "")) ? "rescue" : "fill",
raw_text: JSON.stringify(raw),
confidence: "high",
extraction_method: "structured",
missing_fields: missingFields(role, city, stateRaw, count),
};
}
function normalizeState(s: string | null | undefined): string | null {
if (!s) return null;
const trimmed = s.trim();
if (STATE_ABBRS.has(trimmed.toUpperCase())) return trimmed.toUpperCase();
const full = trimmed.toLowerCase();
if (STATE_NAMES_TO_ABBR[full]) return STATE_NAMES_TO_ABBR[full];
return null;
}
function missingFields(role: any, city: any, state: any, count: any): string[] {
const out: string[] = [];
if (!role) out.push("role");
if (!city) out.push("city");
if (!state) out.push("state");
if (count === null || count === undefined) out.push("count");
return out;
}
// Regex path — handles structured-ish text like "3 welders in Nashville TN".
// Returns partial extraction; LLM path fills the rest if this leaves
// too many missing fields.
function fromRegex(text: string): NormalizedInput {
const lower = text.toLowerCase();
// Count: "3 welders" or "need 5" or "fill 8 safety coordinators"
let count: number | null = null;
const countMatch = text.match(/\b(?:need|fill|book|hire|schedule|want)\s+(\d+)\b/i)
?? text.match(/\b(\d+)\s+(?:x\s+)?[A-Za-z]/);
if (countMatch) count = parseInt(countMatch[1], 10);
// Role: longest prefix match from KNOWN_ROLES (case-insensitive).
// Plural forms ("welders") match "Welder" via endsWith("s") strip.
let role: string | null = null;
for (const known of KNOWN_ROLES.sort((a, b) => b.length - a.length)) {
const needle = known.toLowerCase();
if (lower.includes(needle)) { role = known; break; }
if (needle.endsWith("r") && lower.includes(needle + "s")) { role = known; break; }
}
// City, state: "Nashville TN" or "Nashville, Tennessee" or "Nashville, TN".
// City capture is 1-3 capitalized words — anything longer is usually
// the surrounding phrase ("Forklift Operators in Chicago"). Prefer
// "in {city}" anchor when present.
let city: string | null = null;
let state: string | null = null;
const cityPat = `([A-Z][a-zA-Z.'-]+(?:\\s+[A-Z][a-zA-Z.'-]+){0,2})`;
// First preference: "in {City}, XX" or "in {City} XX"
const inAbbrev = text.match(new RegExp(`\\bin\\s+${cityPat},?\\s+([A-Z]{2})\\b`));
if (inAbbrev && STATE_ABBRS.has(inAbbrev[2])) {
city = inAbbrev[1].trim();
state = inAbbrev[2];
} else {
// Bare "{City}, XX" (no "in" anchor)
const bareAbbrev = text.match(new RegExp(`${cityPat},\\s+([A-Z]{2})\\b`));
if (bareAbbrev && STATE_ABBRS.has(bareAbbrev[2])) {
city = bareAbbrev[1].trim();
state = bareAbbrev[2];
} else {
// "{City}, FullName" variant. Can't use case-insensitive flag
// because it would let cityPat's [A-Z] match lowercase letters.
// Instead try both exact-case and Title-case variants.
for (const [full, abbr] of Object.entries(STATE_NAMES_TO_ABBR)) {
const fullTitle = full.split(" ").map(w => w[0].toUpperCase() + w.slice(1)).join(" ");
const variants = [full, fullTitle];
for (const variant of variants) {
const re = new RegExp(`\\bin\\s+${cityPat},?\\s+${variant}\\b`);
const m = text.match(re);
if (m) { city = m[1].trim(); state = abbr; break; }
}
if (city) break;
// Bare "{City}, FullName" (no "in")
for (const variant of variants) {
const re = new RegExp(`${cityPat},\\s+${variant}\\b`);
const m = text.match(re);
if (m) { city = m[1].trim(); state = abbr; break; }
}
if (city) break;
}
}
}
// Intent
let intent: NormalizedInput["intent"] = "unknown";
for (const h of INTENT_HINTS) {
if (h.re.test(text)) { intent = h.intent; break; }
}
const missing = missingFields(role, city, state, count);
return {
role, city, state, count,
client: null,
deadline: null,
intent,
raw_text: text,
confidence: missing.length === 0 ? "high" : missing.length <= 2 ? "medium" : "low",
extraction_method: "regex",
missing_fields: missing,
};
}
// LLM path — when regex leaves too many fields missing, ask a small
// local model to extract what's present. Uses `think:false` and a
// strict JSON prompt so the call stays cheap.
async function fromLLM(text: string, fallback: NormalizedInput): Promise<NormalizedInput> {
const prompt = `Extract the staffing request shape from this text. Respond with ONLY a JSON object, no prose.
Text: ${text}
Required shape:
{
"role": "string | null (one of: Welder, Forklift Operator, Warehouse Associate, Machine Operator, Loader, Material Handler, Assembler, Quality Tech, Picker, Packer, Shipping Clerk, Receiving Clerk, CNC Operator, Maintenance Tech, Electrician, Tool & Die Maker, Safety Coordinator, Logistics Coordinator, Packaging Operator, Sanitation Worker, Line Lead, or null if unclear)",
"city": "string | null (US city name, properly capitalized)",
"state": "string | null (two-letter abbrev like TN, IL)",
"count": "number | null (integer number of workers needed)",
"client": "string | null (company/client name if mentioned)",
"deadline": "string | null (ISO date YYYY-MM-DD if a specific date is mentioned, else null)",
"intent": "string (one of: fill, lookup, rescue, rebuild, unknown)"
}
Return the JSON object now:`;
try {
const raw = await generateContinuable("qwen3:latest", prompt, {
max_tokens: 400,
shape: "json",
think: false,
max_continuations: 2,
});
const match = raw.match(/\{[\s\S]*\}/);
if (!match) return fallback;
const parsed = JSON.parse(match[0]);
const role = typeof parsed.role === "string" && parsed.role !== "null" ? parsed.role : null;
const city = typeof parsed.city === "string" && parsed.city !== "null" ? parsed.city : null;
const state = normalizeState(parsed.state);
const count = typeof parsed.count === "number" ? parsed.count
: typeof parsed.count === "string" ? parseInt(parsed.count, 10) || null
: null;
const client = typeof parsed.client === "string" && parsed.client !== "null" ? parsed.client : null;
const deadline = typeof parsed.deadline === "string" && parsed.deadline !== "null" ? parsed.deadline : null;
const intent = ["fill", "lookup", "rescue", "rebuild", "unknown"].includes(parsed.intent)
? parsed.intent as NormalizedInput["intent"]
: "unknown";
const missing = missingFields(role, city, state, count);
return {
role: role ?? fallback.role,
city: city ?? fallback.city,
state: state ?? fallback.state,
count: count ?? fallback.count,
client: client ?? fallback.client,
deadline: deadline ?? fallback.deadline,
intent: intent !== "unknown" ? intent : fallback.intent,
raw_text: text,
confidence: missing.length === 0 ? "high" : missing.length <= 2 ? "medium" : "low",
extraction_method: "llm",
missing_fields: missing,
};
} catch {
return fallback;
}
}
// Top-level normalizer. Auto-detects input shape:
// - Object with role/city → fast structured path
// - String with enough regex signal → regex path
// - Low-signal string → LLM path with regex seed
export async function normalizeInput(raw: unknown): Promise<NormalizedInput> {
// Structured path
if (raw && typeof raw === "object") {
const s = fromStructured(raw);
if (s) return s;
}
// String path
const text = typeof raw === "string" ? raw
: raw && typeof raw === "object" ? JSON.stringify(raw)
: String(raw);
const reg = fromRegex(text);
// If regex got at least role + city, trust it.
if (reg.role && reg.city) {
return reg;
}
// Otherwise try LLM fallback, using regex result as seed.
return await fromLLM(text, reg);
}
// Synchronous-only variant — returns whatever regex/structured can get
// without an LLM call. Useful when caller can't await (e.g. a prompt
// template).
export function normalizeInputSync(raw: unknown): NormalizedInput {
if (raw && typeof raw === "object") {
const s = fromStructured(raw);
if (s) return s;
}
const text = typeof raw === "string" ? raw
: raw && typeof raw === "object" ? JSON.stringify(raw)
: String(raw);
return fromRegex(text);
}
+4 -4
View File
@@ -25,8 +25,8 @@ import {
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
const EXECUTOR_MODEL = "mistral:latest";
const REVIEWER_MODEL = "qwen2.5:latest";
const EXECUTOR_MODEL = "qwen3.5:latest";
const REVIEWER_MODEL = "qwen3:latest";
const MAX_TURNS = 12; // executor turns; reviewer gets one per
const MAX_CONSECUTIVE_DRIFTS = 3; // drift-cycle blown → give up
@@ -148,7 +148,7 @@ async function main() {
// --- EXECUTOR TURN ---
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), {
temperature: 0.2,
max_tokens: 600,
max_tokens: 1200,
});
let execAction: Action;
try {
@@ -191,7 +191,7 @@ async function main() {
// --- REVIEWER TURN ---
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), {
temperature: 0.1,
max_tokens: 400,
max_tokens: 1000,
});
let revAction: Action;
try {
+4 -4
View File
@@ -32,8 +32,8 @@ import {
callTool,
} from "./agent.ts";
const EXECUTOR_MODEL = "mistral:latest";
const REVIEWER_MODEL = "qwen2.5:latest";
const EXECUTOR_MODEL = "qwen3.5:latest";
const REVIEWER_MODEL = "qwen3:latest";
const MAX_TURNS = 12;
const MAX_CONSECUTIVE_DRIFTS = 3;
const INDEX_NAME = "workers_500k_v1";
@@ -75,7 +75,7 @@ async function runOrchestrator(task: TaskSpec, prefix: string): Promise<RunResul
turn += 1;
// Executor
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 600 });
const execRaw = await generate(EXECUTOR_MODEL, executorPrompt(task, log), { temperature: 0.2, max_tokens: 1200 });
const execAction = parseAction(execRaw, "executor");
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: execAction.kind as any, content: execAction });
@@ -95,7 +95,7 @@ async function runOrchestrator(task: TaskSpec, prefix: string): Promise<RunResul
}
// Reviewer
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 400 });
const revRaw = await generate(REVIEWER_MODEL, reviewerPrompt(task, log), { temperature: 0.1, max_tokens: 1000 });
const revAction = parseAction(revRaw, "reviewer");
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "critique", content: revAction });
+584 -47
View File
@@ -29,23 +29,44 @@ import {
sqlQuery,
generate,
generateCloud,
generateContinuable,
parseAction,
executorPrompt,
reviewerPrompt,
GATEWAY,
} from "./agent.ts";
import { indexRun, recommendFor, loadRecommendation, snapshotConfig, computeSignature, type PathwayRecommendation } from "./kb.ts";
import { createHash } from "node:crypto";
// Phase 24 — observer HTTP endpoint. Scenarios POST per-event
// outcomes here so the observer's analyzer + playbook builder see
// them. Best-effort: if observer is down, scenario still proceeds.
const OBSERVER_URL = process.env.LH_OBSERVER_URL ?? "http://localhost:3800";
async function postObserverEvent(payload: Record<string, any>): Promise<void> {
try {
await fetch(`${OBSERVER_URL}/event`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(2000),
});
} catch {
// Observer down — silent skip. The scenario's own logs are still
// the source of truth; observer integration is an augmentation.
}
}
import { mkdir, writeFile, appendFile } from "node:fs/promises";
import { join } from "node:path";
// 2026-04-20 — reverted to mistral executor after trying qwen2.5.
// qwen2.5 emits malformed JSON (trailing `)` garbage, unterminated
// strings) when asked for tool calls. mistral drops fields occasionally
// but produces valid JSON. With optional `question` default + lean
// prompt + schema lock, mistral seals baseline + recurring reliably.
// Complex scenarios (5-fill, emergency, misplacement) remain flaky —
// real Phase 20+ problem (larger model or constrained decoding needed).
const EXECUTOR_MODEL = "mistral:latest";
const REVIEWER_MODEL = "qwen2.5:latest";
// 2026-04-21 — executor is now qwen3.5:latest (9.7B, 262K context,
// thinking model, emits clean JSON). Replaces mistral, which produced
// malformed JSON on complex SQL filters (bare IN-clause identifiers,
// unclosed braces) regardless of prompt — decoder-level bug that all
// 5 events hit across 4 A/B test runs. qwen3.5 tested clean on first
// try with 800 max_tokens.
const EXECUTOR_MODEL = "qwen3.5:latest";
const REVIEWER_MODEL = "qwen3:latest";
const DRAFT_MODEL = "qwen2.5:latest"; // artifact generation; short outputs
// T3 overview tier. Called sparingly — NOT per tool call. Two insertion
@@ -78,12 +99,79 @@ const OVERVIEW_MODEL = process.env.LH_OVERVIEW_MODEL
: (T3_TIER?.local_fallback?.model ?? "gpt-oss:20b"));
const T3_CHECKPOINT_EVERY = Number(process.env.LH_T3_CHECKPOINT_EVERY ?? 3);
const T3_DISABLED = process.env.LH_T3_DISABLE === "1";
// Phase 22 item B — cloud-assisted retry on event failure. Default ON
// when cloud T3 is enabled (LH_OVERVIEW_CLOUD=1) since that's the only
// path where the rescue call gets a model worth asking. Disable with
// LH_RETRY_ON_FAIL=0 to compare baseline outcomes without rescue.
const RETRY_ON_FAIL = process.env.LH_RETRY_ON_FAIL !== "0";
// Phase 23 refinement — per-staffer tool_level overrides. Evaluated
// per run in main() once we know the spec's staffer. These are
// package-scoped mutable slots intentionally — the primary constant
// above is the DEFAULT; main() flips them for the duration of the run
// based on staffer.tool_level before calling anything else.
let ACTIVE_EXECUTOR = EXECUTOR_MODEL;
let ACTIVE_REVIEWER = REVIEWER_MODEL;
let ACTIVE_EXECUTOR_CLOUD = false;
let ACTIVE_REVIEWER_CLOUD = false;
let ACTIVE_T3_DISABLED = T3_DISABLED;
let ACTIVE_OVERVIEW_CLOUD = OVERVIEW_CLOUD;
let ACTIVE_RETRY_ON_FAIL = RETRY_ON_FAIL;
function applyToolLevel(level: Staffer["tool_level"] | undefined): void {
// Start from env defaults each time so previous staffer's overrides
// don't leak.
ACTIVE_EXECUTOR = EXECUTOR_MODEL;
ACTIVE_REVIEWER = REVIEWER_MODEL;
ACTIVE_EXECUTOR_CLOUD = false;
ACTIVE_REVIEWER_CLOUD = false;
ACTIVE_T3_DISABLED = T3_DISABLED;
ACTIVE_OVERVIEW_CLOUD = OVERVIEW_CLOUD;
ACTIVE_RETRY_ON_FAIL = RETRY_ON_FAIL;
if (!level) return;
switch (level) {
case "full":
ACTIVE_OVERVIEW_CLOUD = true;
break;
case "local":
ACTIVE_OVERVIEW_CLOUD = false;
break;
case "basic":
// qwen2.5 collapsed on this workload (0/14 fill). Replace with
// cloud kimi-k2.5 — same family as k2.6 (which requires a paid
// tier), strong at tool calling. kimi-k2.6 is targeted when the
// subscription upgrades.
ACTIVE_EXECUTOR = "kimi-k2.5";
ACTIVE_EXECUTOR_CLOUD = true;
ACTIVE_REVIEWER = "qwen3:latest"; // local reviewer stays cheap
ACTIVE_OVERVIEW_CLOUD = false;
ACTIVE_RETRY_ON_FAIL = false;
break;
case "minimal":
// Same executor as basic but strip the overseer + rescue.
// Proves whether playbook inheritance alone carries the load.
ACTIVE_EXECUTOR = "kimi-k2.5";
ACTIVE_EXECUTOR_CLOUD = true;
ACTIVE_REVIEWER = "qwen3:latest";
ACTIVE_T3_DISABLED = true;
ACTIVE_OVERVIEW_CLOUD = false;
ACTIVE_RETRY_ON_FAIL = false;
break;
}
}
// Dispatcher: route T3 calls to local sidecar or Ollama Cloud depending
// on the LH_OVERVIEW_CLOUD flag. Hot-path T1/T2 always stay local.
// T3 outputs are free-form prose (lesson/hint), so shape=text — the
// continuation primitive treats any non-empty response as complete.
async function overviewGenerate(prompt: string, opts: { temperature?: number; max_tokens?: number } = {}): Promise<string> {
if (OVERVIEW_CLOUD) return generateCloud(OVERVIEW_MODEL, prompt, opts);
return generate(OVERVIEW_MODEL, prompt, opts);
return generateContinuable(OVERVIEW_MODEL, prompt, {
temperature: opts.temperature,
max_tokens: opts.max_tokens ?? 1000,
shape: "text",
max_continuations: 2,
cloud: ACTIVE_OVERVIEW_CLOUD,
});
}
const MAX_TURNS = 14;
@@ -109,10 +197,53 @@ interface FillEvent {
replaces_event?: string; // misplacement back-ref for reporting
}
// Contract terms describe the business constraints around a scenario:
// how much can the client pay, by when, and how is a local candidate
// bonused vs an out-of-area one. T3 checkpoint + cloud rescue pass
// these into the overseer's prompt so pivots respect budget (not just
// geography). Optional — legacy scenarios without a contract degrade
// to the original "nearest-city" pivot logic.
interface ContractTerms {
deadline: string; // ISO date — "2026-05-05"
budget_total_usd?: number; // optional total cap across the contract
budget_per_hour_max?: number; // per-worker hourly cap
local_bonus_per_hour?: number; // extra $/hr for in-radius candidates
local_bonus_radius_mi?: number; // radius from the contract city
fill_requirement?: "paramount" | "preferred"; // "paramount" = must fill;
// "preferred" = best-effort
}
// Phase 23 — staffer identity. When a scenario is assigned to a
// specific coordinator, we record their trajectory so KB can
// weight future retrieval by competence. Junior staffers inherit
// Senior staffers' playbooks; top staffers become the training
// signal the system auto-discovers.
interface Staffer {
id: string; // "S-001"
name: string; // "Maria Chen"
tenure_months: number;
role: "senior" | "mid" | "junior" | "trainee";
// Phase 23 refinement — tool_level controls which subsystems this
// staffer's runs get to use. The mechanism always leaves
// playbook_memory ON so inherited playbooks drive the outcome even
// when T3 / cloud rescue / the bigger executor are disabled.
//
// full — qwen3.5 executor + qwen3 reviewer + cloud T3 + rescue
// local — qwen3.5 + qwen3 + local gpt-oss:20b T3 + rescue
// basic — qwen2.5 + qwen2.5 + local T3, no rescue
// minimal — qwen2.5 + qwen2.5, NO T3, NO rescue. Only playbook
// inheritance to lean on. This is the honest test of
// whether the playbook system carries knowledge on its
// own.
tool_level?: "full" | "local" | "basic" | "minimal";
}
interface ScenarioSpec {
client: string;
date: string;
events: FillEvent[];
contract?: ContractTerms;
staffer?: Staffer;
}
interface EventResult {
@@ -128,6 +259,36 @@ interface EventResult {
pool_size?: number; // sql_matches from the first hybrid_search
playbook_citations?: string[];
discovered_pattern?: string; // Path 2 meta-index snapshot per event
diagnostic_log?: LogEntry[]; // SQL filters, pool sizes, drift reasons —
// what T3 needs to diagnose supply issues
// vs surface symptoms. Present on both
// failed and successful events.
// Phase 22 item B — cloud-assisted retry fields. When a first attempt
// fails and LH_RETRY_ON_FAIL is on, T3 cloud proposes a pivot; if
// adopted, the retry re-runs the event with the new (city, role,
// count) and records the result in `retry_result`. original_event is
// the pre-pivot spec, kept for the retrospective so we can show the
// coordinator what changed.
retry_attempt?: number;
retry_remediation?: {
proposed_city?: string;
proposed_role?: string;
proposed_count?: number;
rationale: string;
cloud_model: string;
cloud_duration_secs: number;
};
retry_result?: Omit<EventResult, "retry_attempt" | "retry_remediation" | "retry_result" | "diagnostic_log">;
original_event?: FillEvent; // what the event was before pivot
}
interface CloudRemediation {
retry: boolean;
new_city?: string;
new_state?: string;
new_role?: string;
new_count?: number;
rationale: string;
}
interface RosterEntry {
@@ -147,6 +308,7 @@ interface ScenarioContext {
results: EventResult[];
gap_signals: Array<{ event: string; category: string; detail: string }>;
prior_lessons: PriorLesson[];
pathway_rec?: PathwayRecommendation | null;
}
interface PriorLesson {
@@ -332,9 +494,12 @@ async function runAgentFill(
task: TaskSpec,
extra_guidance: string,
exclude_worker_ids: string[],
sharedLog?: LogEntry[], // If provided, runAgentFill appends here too so
// callers can diagnose AFTER a throw. Empty by
// default — passing this in is opt-in.
): Promise<AgentFillOutcome> {
const t0 = Date.now();
const log: LogEntry[] = [];
const log: LogEntry[] = sharedLog ?? [];
let turn = 0;
let consecutiveDrifts = 0;
let sealed: { fills: Fill[]; approach: string } | null = null;
@@ -365,20 +530,37 @@ async function runAgentFill(
while (turn < MAX_TURNS && !sealed) {
turn += 1;
const execRaw = await generate(
EXECUTOR_MODEL,
// generateContinuable: if the model truncates mid-JSON (thinking
// ate the budget, or payload was just long), auto-continue with the
// partial as scratchpad until braces balance and JSON parses.
// No more "bump max_tokens until it stops truncating" tourniquet.
// think:false — executor emits structured JSON, doesn't need hidden
// reasoning. Burning ~650 thinking tokens on a 400-token JSON was
// exactly the bug we just solved.
const execRaw = await generateContinuable(
ACTIVE_EXECUTOR,
withExtras(executorPrompt(task, log)),
{ temperature: 0.2, max_tokens: 600 },
{
temperature: 0.2,
max_tokens: 800,
shape: "json",
max_continuations: 3,
think: false,
cloud: ACTIVE_EXECUTOR_CLOUD,
on_continuation: (n, len) =>
append({ turn, role: "executor", model: ACTIVE_EXECUTOR, kind: "note",
content: { continuation: n, combined_chars: len } }),
},
);
let execAction: Action;
try {
execAction = parseAction(execRaw, "executor");
} catch (e) {
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "error",
append({ turn, role: "executor", model: ACTIVE_EXECUTOR, kind: "error",
content: { message: (e as Error).message, raw: execRaw.slice(0, 300) } });
throw e;
}
append({ turn, role: "executor", model: EXECUTOR_MODEL,
append({ turn, role: "executor", model: ACTIVE_EXECUTOR,
kind: execAction.kind as any, content: execAction });
if (execAction.kind === "tool_call") {
@@ -397,7 +579,7 @@ async function runAgentFill(
}
}
const trimmed = trimResult(filtered);
append({ turn, role: "executor", model: EXECUTOR_MODEL,
append({ turn, role: "executor", model: ACTIVE_EXECUTOR,
kind: "tool_result", content: trimmed });
// Accumulate playbook citations from any hybrid result that
@@ -410,7 +592,7 @@ async function runAgentFill(
}
}
} catch (e) {
append({ turn, role: "executor", model: EXECUTOR_MODEL, kind: "tool_result",
append({ turn, role: "executor", model: ACTIVE_EXECUTOR, kind: "tool_result",
content: { error: (e as Error).message, tool: execAction.tool } });
consecutiveDrifts += 1;
if (consecutiveDrifts >= MAX_CONSECUTIVE_DRIFTS) {
@@ -419,20 +601,30 @@ async function runAgentFill(
}
}
const revRaw = await generate(
REVIEWER_MODEL,
const revRaw = await generateContinuable(
ACTIVE_REVIEWER,
withExtras(reviewerPrompt(task, log)),
{ temperature: 0.1, max_tokens: 400 },
{
temperature: 0.1,
max_tokens: 600,
shape: "json",
max_continuations: 3,
think: false,
cloud: ACTIVE_REVIEWER_CLOUD,
on_continuation: (n, len) =>
append({ turn, role: "reviewer", model: ACTIVE_REVIEWER, kind: "note",
content: { continuation: n, combined_chars: len } }),
},
);
let revAction: Action;
try {
revAction = parseAction(revRaw, "reviewer");
} catch (e) {
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "error",
append({ turn, role: "reviewer", model: ACTIVE_REVIEWER, kind: "error",
content: { message: (e as Error).message, raw: revRaw.slice(0, 300) } });
throw e;
}
append({ turn, role: "reviewer", model: REVIEWER_MODEL,
append({ turn, role: "reviewer", model: ACTIVE_REVIEWER,
kind: "critique", content: revAction });
if (revAction.kind !== "critique") throw new Error(`reviewer emitted non-critique: ${revAction.kind}`);
@@ -457,7 +649,7 @@ async function runAgentFill(
throw new Error(`consensus proposed excluded worker ${f.candidate_id}`);
}
}
append({ turn, role: "reviewer", model: REVIEWER_MODEL, kind: "consensus_done",
append({ turn, role: "reviewer", model: ACTIVE_REVIEWER, kind: "consensus_done",
content: { fills: execAction.fills } });
sealed = { fills: execAction.fills, approach: execAction.rationale ?? "multi-agent hybrid" };
}
@@ -548,7 +740,14 @@ CAST(reliability AS DOUBLE) > 0.7.`;
).join("\n")
: "";
return `${schemaLock}\n\nEVENT FOCUS:\n${base}${priorHint}`;
// Phase 22 pathway recommendation — if the KB synthesized a "best
// path" from neighbor runs, inject it as concrete pre-run guidance.
// Keep terse; the full rationale lives in the KB file.
const pathwayHint = ctx.pathway_rec && ctx.pathway_rec.pathway_notes
? `\n\nKB PATHWAY RECOMMENDATION (synthesized from ${ctx.pathway_rec.neighbors_consulted.length} neighbor runs, confidence=${ctx.pathway_rec.confidence}):\n${ctx.pathway_rec.pathway_notes.slice(0, 600)}`
: "";
return `${schemaLock}\n\nEVENT FOCUS:\n${base}${priorHint}${pathwayHint}`;
}
// =================== Artifact generation ===================
@@ -595,7 +794,7 @@ Body (4-6 lines max). Be specific about:
- Any scenario flag: ${event.scenario_note ?? "(none)"}
Workers:
${outcome.fills.map(f => `- ${f.name} (${f.reason.slice(0, 60)})`).join("\n")}
${outcome.fills.map(f => `- ${f.name}${f.reason ? " (" + f.reason.slice(0, 60) + ")" : ""}`).join("\n")}
Respond with only the email. No commentary.`;
@@ -636,8 +835,14 @@ async function runEvent(event: FillEvent, ctx: ScenarioContext): Promise<EventRe
const gap_signals: string[] = [];
let outcome: AgentFillOutcome;
// Share the log so a drift-abort or tool-error throw still leaves us
// with a diagnostic trail — T3 checkpoint needs SQL filters, pool
// sizes, and reviewer drift notes to diagnose ROOT CAUSE (0-supply
// cities, semantic fallback on empty SQL result) rather than surface
// symptoms ("drift flagged").
const sharedLog: LogEntry[] = [];
try {
outcome = await runAgentFill(task, guidanceFor(event, ctx), excludeIds);
outcome = await runAgentFill(task, guidanceFor(event, ctx), excludeIds, sharedLog);
} catch (e) {
return {
event,
@@ -647,6 +852,7 @@ async function runEvent(event: FillEvent, ctx: ScenarioContext): Promise<EventRe
duration_secs: (Date.now() - t0) / 1000,
error: (e as Error).message,
gap_signals: [`drift_or_tool: ${(e as Error).message}`],
diagnostic_log: sharedLog,
};
}
@@ -779,6 +985,7 @@ async function runEvent(event: FillEvent, ctx: ScenarioContext): Promise<EventRe
pool_size: outcome.first_sql_matches,
playbook_citations: outcome.playbook_citations,
discovered_pattern,
diagnostic_log: sharedLog,
};
}
@@ -836,38 +1043,101 @@ interface OverviewCheckpoint {
risk: string; // T3's named risk flag
}
// Extract supply/diagnostic signals from the event's log so T3 can
// reason about ROOT CAUSES (zero-supply cities, semantic fallback on
// empty SQL results, repeated drift reasons) instead of surface
// symptoms ("drift flagged").
function extractDiagnostics(log: LogEntry[] | undefined): {
sql_filters: string[];
hybrid_row_counts: number[];
sql_errors: string[];
drift_reasons: string[];
} {
const sql_filters: string[] = [];
const hybrid_row_counts: number[] = [];
const sql_errors: string[] = [];
const drift_reasons: string[] = [];
if (!log) return { sql_filters, hybrid_row_counts, sql_errors, drift_reasons };
for (const e of log) {
if (e.kind === "tool_call" && (e.content?.tool === "hybrid_search" || e.content?.tool === "sql")) {
const args = e.content?.args ?? {};
if (args.sql_filter) sql_filters.push(String(args.sql_filter).slice(0, 200));
else if (args.query) sql_filters.push(String(args.query).slice(0, 200));
}
if (e.kind === "tool_result") {
const rows = e.content?.rows;
if (typeof rows === "number") hybrid_row_counts.push(rows);
const err = e.content?.error;
if (typeof err === "string") sql_errors.push(err.slice(0, 200));
}
if (e.kind === "critique" && e.content?.verdict === "drift") {
const note = e.content?.notes;
if (typeof note === "string") drift_reasons.push(note.slice(0, 200));
}
}
return { sql_filters, hybrid_row_counts, sql_errors, drift_reasons };
}
// Render contract terms into a prompt-friendly block. Empty string when
// no contract is attached — keeps legacy scenarios unaffected.
function contractBlock(contract: ContractTerms | undefined): string {
if (!contract) return "";
const parts: string[] = [];
parts.push(`deadline=${contract.deadline}`);
if (contract.fill_requirement) parts.push(`fill=${contract.fill_requirement}`);
if (contract.budget_per_hour_max) parts.push(`budget/hr max=$${contract.budget_per_hour_max}`);
if (contract.budget_total_usd) parts.push(`total budget=$${contract.budget_total_usd}`);
if (contract.local_bonus_per_hour) parts.push(`local bonus=+$${contract.local_bonus_per_hour}/hr`);
if (contract.local_bonus_radius_mi) parts.push(`local radius=${contract.local_bonus_radius_mi}mi`);
return `\nCONTRACT TERMS: ${parts.join(", ")}`;
}
async function runOverviewCheckpoint(
event: FillEvent,
result: EventResult,
prior: EventResult[],
contract?: ContractTerms,
): Promise<OverviewCheckpoint | null> {
if (T3_DISABLED) return null;
if (ACTIVE_T3_DISABLED) return null;
const start = Date.now();
const priorSummary = prior.slice(-3).map(p =>
`- ${p.event.at} ${p.event.kind} ${p.event.role}×${p.event.count} → ${p.ok ? p.fills.length + "/" + p.event.count + " filled" : "FAIL"}; pool=${p.pool_size ?? "?"}; cites=${p.playbook_citations?.length ?? 0}`
`- ${p.event.at} ${p.event.kind} ${p.event.role}×${p.event.count} in ${p.event.city},${p.event.state} → ${p.ok ? p.fills.length + "/" + p.event.count + " filled" : "FAIL(" + (p.error?.slice(0, 40) ?? "?") + ")"}; pool=${p.pool_size ?? "?"}; cites=${p.playbook_citations?.length ?? 0}`
).join("\n");
const diag = extractDiagnostics(result.diagnostic_log);
const diagBlock = result.ok
? "" // only include for failures — successful events don't need the forensic detail
: `\n\nFAILURE FORENSICS (raw signals the agent saw — use to diagnose root cause):\n`
+ ` SQL filters attempted (first 3):\n${diag.sql_filters.slice(0, 3).map(f => " - " + f).join("\n") || " (none)"}\n`
+ ` Row counts per tool call: ${diag.hybrid_row_counts.slice(0, 5).join(", ") || "(none)"}\n`
+ (diag.sql_errors.length > 0 ? ` SQL errors: ${diag.sql_errors.slice(0, 2).join(" | ")}\n` : "")
+ ` Reviewer drift reasons (first 2): ${diag.drift_reasons.slice(0, 2).join(" | ") || "(none recorded)"}\n`
+ ` KEY CHECK: if row counts were non-zero but the reviewer said "no matches for ${event.city}", the SQL filter yielded 0 rows in the target city and vector search FALLED BACK to workers in OTHER cities. That is a ZERO-SUPPLY condition — pivot city or broaden role.`;
const thisOne = `This event: ${event.at} ${event.kind} ${event.role}×${event.count} in ${event.city}, ${event.state}. `
+ `Outcome: ${result.ok ? "filled " + result.fills.length + "/" + event.count : "FAILED: " + (result.error ?? "unknown")}. `
+ `Pool size: ${result.pool_size ?? "n/a"}. Turns: ${result.turns}. Playbook citations: ${result.playbook_citations?.length ?? 0}. `
+ `Gap signals: ${result.gap_signals.join("; ") || "none"}.`;
const prompt = `You are the overview reviewer for a staffing coordinator agent system. A mid-day checkpoint has been triggered.
${contractBlock(contract)}
Recent events (most recent last):
${priorSummary || "(no prior events)"}
${thisOne}
${thisOne}${diagBlock}
Your job: emit ONE risk flag (≤6 words) and ONE actionable hint (≤25 words) for the NEXT event. Be concrete: name the role, city, or worker class if relevant. Do not restate what happened. Think step by step, then output strictly as:
Your job: emit ONE risk flag (≤8 words) and ONE actionable hint (≤40 words) for the NEXT event. Be concrete: name the role, city, worker class, OR a geographic pivot (e.g. "pivot to {city}, {state} ({distance}mi) — still in bonus radius"). Do not restate what happened. If the failure was zero-supply, your hint MUST propose a specific alternative city or role. If contract has local_bonus_radius_mi, prefer alternates within that radius before proposing farther pivots. Think step by step, then output strictly as:
RISK: <flag>
HINT: <hint>`;
let text = "";
try {
text = await overviewGenerate(prompt, { temperature: 0.2, max_tokens: 600 });
// overviewGenerate routes through generateContinuable — if thinking
// ate the initial budget, it auto-continues rather than requiring
// us to guess a safe max_tokens upfront.
text = await overviewGenerate(prompt, { temperature: 0.2, max_tokens: 800 });
} catch (e) {
return {
after_event: event.at,
@@ -893,12 +1163,85 @@ HINT: <hint>`;
};
}
async function runCrossDayLesson(ctx: ScenarioContext, checkpoints: OverviewCheckpoint[]): Promise<string | null> {
if (T3_DISABLED) return null;
// Phase 22 item B — rescue path. When an event fails, feed the
// failure trace (SQL, pool, drift reasons, gap signals) to cloud T3
// and ask for a concrete pivot: new city, new role, or new count.
// Returns null if cloud can't help or flag says "impossible, don't
// retry". Same diagnostic enrichment as the checkpoint prompt so
// cloud reasons from raw signals, not symptoms.
async function requestCloudRemediation(
event: FillEvent,
result: EventResult,
contract?: ContractTerms,
): Promise<{ remediation: CloudRemediation; duration_secs: number } | null> {
if (ACTIVE_T3_DISABLED) return null;
const start = Date.now();
const diag = extractDiagnostics(result.diagnostic_log);
const eventDigest = ctx.results.map(r =>
`- ${r.event.at} ${r.event.kind} ${r.event.role}×${r.event.count} ${r.event.city},${r.event.state} → ${r.ok ? r.fills.length + " filled" : "FAIL"}; pool=${r.pool_size ?? "?"}; turns=${r.turns}; cites=${r.playbook_citations?.length ?? 0}; gaps=${r.gap_signals.length}`
).join("\n");
const diagBlock = `SQL filters attempted (first 3):\n${diag.sql_filters.slice(0, 3).map(f => " - " + f).join("\n") || " (none)"}\n`
+ `Row counts per tool call: ${diag.hybrid_row_counts.slice(0, 5).join(", ") || "(none)"}\n`
+ (diag.sql_errors.length > 0 ? `SQL errors: ${diag.sql_errors.slice(0, 2).join(" | ")}\n` : "")
+ `Reviewer drift reasons (first 3): ${diag.drift_reasons.slice(0, 3).join(" | ") || "(none recorded)"}\n`
+ `Gap signals: ${result.gap_signals.join(" | ") || "none"}`;
const prompt = `You are the rescue agent for a staffing coordinator system. An event just FAILED. Diagnose root cause, then propose ONE concrete retry plan.
FAILED EVENT:
at=${event.at} kind=${event.kind} role=${event.role} count=${event.count} city=${event.city} state=${event.state}
outcome: ${result.error ?? "unknown failure"}
turns used: ${result.turns}
pool surfaced: ${result.pool_size ?? "n/a"}
${contractBlock(contract)}
RAW DIAGNOSTICS (what the agent actually saw):
${diagBlock}
Respond with a JSON object (NOTHING else, no prose before or after, no markdown):
{
"retry": true | false,
"new_city": "string (same as original if no pivot)",
"new_state": "XX (two-letter abbreviation)",
"new_role": "string (same as original if no pivot)",
"new_count": <integer (same as original if no pivot)>,
"rationale": "2-3 sentences explaining the fix or why retry is futile"
}
RULES:
- Set retry=true only if you believe the pivot will likely succeed.
- If the city has genuine zero supply, pivot to the NEAREST alternate city with comparable labor pool (name a specific one).
- If the role is uniquely scarce, either broaden to a synonym role OR reduce count to something achievable.
- If no pivot seems viable, set retry=false — wasting a retry is worse than declaring impossible.
- Keep new_count realistic. Don't propose 5× in a city that clearly only has 2 workers.
- new_city is the CITY name only (e.g. "Clarksville"). Put the two-letter state in new_state. Don't concat them.
${contract ? `- CONTRACT AWARENESS: fill_requirement=${contract.fill_requirement ?? "preferred"}. ${contract.fill_requirement === "paramount" ? "MUST fill — splitting across more cities is acceptable if one city can't cover headcount." : "Best-effort — declining is OK if budget can't absorb pivot premium."} ${contract.local_bonus_radius_mi ? `Local bonus radius is ${contract.local_bonus_radius_mi}mi from ${event.city}, ${event.state}; prefer pivots WITHIN that radius even if they have slightly less supply, since those candidates earn the local bonus premium.` : ""} ${contract.budget_per_hour_max ? `Per-hour budget max is $${contract.budget_per_hour_max}.` : ""}` : ""}`;
let raw = "";
try {
raw = await overviewGenerate(prompt, { temperature: 0.2, max_tokens: 900 });
} catch (e) {
return null;
}
const duration_secs = (Date.now() - start) / 1000;
try {
const m = raw.match(/\{[\s\S]*\}/);
if (!m) return null;
const parsed = JSON.parse(m[0]) as CloudRemediation;
if (typeof parsed.retry !== "boolean") return null;
return { remediation: parsed, duration_secs };
} catch {
return null;
}
}
async function runCrossDayLesson(ctx: ScenarioContext, checkpoints: OverviewCheckpoint[]): Promise<string | null> {
if (ACTIVE_T3_DISABLED) return null;
const eventDigest = ctx.results.map(r => {
const diag = extractDiagnostics(r.diagnostic_log);
const zeroSupply = !r.ok && diag.drift_reasons.some(n => /no match|no candidat|0 rows/i.test(n));
const supplyTag = zeroSupply ? " [ZERO-SUPPLY: pivot city needed]" : "";
return `- ${r.event.at} ${r.event.kind} ${r.event.role}×${r.event.count} ${r.event.city},${r.event.state} → ${r.ok ? r.fills.length + " filled" : "FAIL(" + (r.error?.slice(0, 40) ?? "?") + ")"}; pool=${r.pool_size ?? "?"}; turns=${r.turns}; cites=${r.playbook_citations?.length ?? 0}; gaps=${r.gap_signals.length}${supplyTag}`;
}).join("\n");
const checkpointDigest = checkpoints.length > 0
? checkpoints.map(c => `- after ${c.after_event} (${c.event_kind}): risk="${c.risk}" hint="${c.hint}"`).join("\n")
@@ -906,7 +1249,7 @@ async function runCrossDayLesson(ctx: ScenarioContext, checkpoints: OverviewChec
const prompt = `You are the end-of-day lesson writer for a staffing coordinator agent system. The day is done. Distill it.
Client: ${ctx.spec.client} Date: ${ctx.spec.date}
Client: ${ctx.spec.client} Date: ${ctx.spec.date}${contractBlock(ctx.spec.contract)}
Events that ran:
${eventDigest}
@@ -916,6 +1259,8 @@ ${checkpointDigest}
Your job: write ONE actionable lesson for future runs that face similar setups. Target audience: the agent tomorrow. Keep the lesson to 3-5 sentences. No filler, no restating. Think step by step about what pattern repeated, what to pre-fetch, or what to avoid — then write the lesson as plain prose.
PRIORITY: If ANY event carries the [ZERO-SUPPLY: pivot city needed] tag, your lesson MUST name the specific cities that had zero supply and propose the nearest alternate city with roughly the required role count. That is the single most valuable signal for future runs — do not bury it under generic advice. Example: "Gary, IN has no Electricians in the corpus — pivot to Chicago, IL (40mi) or Hammond, IN (10mi) for future fills."
LESSON:`;
try {
@@ -933,7 +1278,7 @@ async function writeRetrospective(ctx: ScenarioContext): Promise<void> {
const lines: string[] = [];
lines.push(`# Scenario retrospective — ${ctx.spec.client}, ${ctx.spec.date}`);
lines.push("");
lines.push(`Executor: \`${EXECUTOR_MODEL}\` Reviewer: \`${REVIEWER_MODEL}\` Draft: \`${DRAFT_MODEL}\` Overview(T3): \`${T3_DISABLED ? "disabled" : OVERVIEW_MODEL + (OVERVIEW_CLOUD ? " (cloud)" : "")}\``);
lines.push(`Executor: \`${ACTIVE_EXECUTOR}\` Reviewer: \`${ACTIVE_REVIEWER}\` Draft: \`${DRAFT_MODEL}\` Overview(T3): \`${ACTIVE_T3_DISABLED ? "disabled" : OVERVIEW_MODEL + (ACTIVE_OVERVIEW_CLOUD ? " (cloud)" : "")}\``);
lines.push(`Prior lessons loaded into executor context: **${ctx.prior_lessons.length}**${ctx.prior_lessons.length > 0 ? " (from " + ctx.prior_lessons.map(p => p.date).join(", ") + ")" : " (baseline — no prior T3 history)"}`);
lines.push("");
@@ -1066,6 +1411,7 @@ async function writeRetrospective(ctx: ScenarioContext): Promise<void> {
// =================== Main driver ===================
async function main() {
const runStart = Date.now();
const specPath = process.argv[2];
const spec: ScenarioSpec = specPath
? JSON.parse(await Bun.file(specPath).text())
@@ -1077,6 +1423,18 @@ async function main() {
const prior_lessons = await loadPriorLessons(spec);
// Phase 22 KB — load any pathway recommendation for this signature.
// The recommender is called at END of prior runs and synthesizes
// configuration + pathway notes from nearest-neighbor history.
// Nothing on first run (cold start); populates over time.
const pathwayRec = await loadRecommendation(spec).catch(() => null);
if (pathwayRec) {
console.log(`▶ KB recommendation loaded: confidence=${pathwayRec.confidence} from ${pathwayRec.neighbors_consulted.length} neighbors`);
if (pathwayRec.pathway_notes) {
console.log(` pathway: ${pathwayRec.pathway_notes.slice(0, 120)}${pathwayRec.pathway_notes.length > 120 ? "…" : ""}`);
}
}
const ctx: ScenarioContext = {
spec,
out_dir,
@@ -1084,6 +1442,7 @@ async function main() {
results: [],
gap_signals: [],
prior_lessons,
pathway_rec: pathwayRec,
};
// Initialize output files
@@ -1107,8 +1466,49 @@ async function main() {
const checkpoints: OverviewCheckpoint[] = [];
// Phase 23 refinement — per-staffer tool_level override. Fires once
// per run. If no staffer or no tool_level, defaults hold.
applyToolLevel(spec.staffer?.tool_level);
// Phase 22 — record the config snapshot each run gets. Lets the
// error_corrections detector diff configs between fail→succeed pairs
// and gives the KB a receipt of what was active for any given
// outcome. Hash computed over the active model set + tool_level so
// the same staffer running back-to-back with no config change
// doesn't clutter the file.
try {
const activeModels = {
executor: ACTIVE_EXECUTOR,
reviewer: ACTIVE_REVIEWER,
overview: OVERVIEW_MODEL,
executor_cloud: String(ACTIVE_EXECUTOR_CLOUD),
overview_cloud: String(ACTIVE_OVERVIEW_CLOUD),
t3_disabled: String(ACTIVE_T3_DISABLED),
tool_level: spec.staffer?.tool_level ?? "default",
};
const configHash = createHash("sha256")
.update(JSON.stringify(activeModels))
.digest("hex")
.slice(0, 16);
await snapshotConfig(
configHash,
activeModels,
`scenario_start ${spec.client} ${spec.date} staffer=${spec.staffer?.id ?? "none"}`,
);
} catch (e) {
console.log(` (config snapshot skipped: ${(e as Error).message})`);
}
console.log(`▶ scenario: ${spec.client}, ${spec.date}, ${spec.events.length} events`);
console.log(`▶ models: exec=${EXECUTOR_MODEL} review=${REVIEWER_MODEL} overview=${T3_DISABLED ? "disabled" : OVERVIEW_MODEL + (OVERVIEW_CLOUD ? " (cloud)" : "")}`);
if (spec.staffer) {
const level = spec.staffer.tool_level ?? "(default)";
console.log(`▶ staffer: ${spec.staffer.id} ${spec.staffer.name} (${spec.staffer.role}, ${spec.staffer.tenure_months}mo, tools=${level})`);
}
if (spec.contract) {
const c = spec.contract;
console.log(`▶ contract: deadline=${c.deadline} fill=${c.fill_requirement ?? "preferred"}${c.budget_per_hour_max ? ` budget=$${c.budget_per_hour_max}/hr` : ""}${c.local_bonus_radius_mi ? ` local_radius=${c.local_bonus_radius_mi}mi+$${c.local_bonus_per_hour ?? 0}` : ""}`);
}
console.log(`▶ models: exec=${ACTIVE_EXECUTOR} review=${ACTIVE_REVIEWER} overview=${ACTIVE_T3_DISABLED ? "disabled" : OVERVIEW_MODEL + (ACTIVE_OVERVIEW_CLOUD ? " (cloud)" : "")}`);
console.log(`▶ out: ${out_dir}\n`);
for (let i = 0; i < spec.events.length; i++) {
@@ -1135,19 +1535,127 @@ async function main() {
}
}
const result = await runEvent(event, ctx);
let result = await runEvent(event, ctx);
// Phase 22 item B — cloud rescue on failure. When an event fails
// and LH_RETRY_ON_FAIL is on (default on when cloud T3 is on), ask
// cloud for a concrete pivot and re-run the event with the new
// (city, role, count). Capped at 1 retry per event to keep the
// budget bounded and avoid infinite loops on genuinely-impossible
// scenarios.
if (!result.ok && ACTIVE_RETRY_ON_FAIL && !ACTIVE_T3_DISABLED) {
console.log(` ▶ cloud rescue requested for ${event.at} ${event.kind}…`);
const rescue = await requestCloudRemediation(event, result, spec.contract);
if (rescue && rescue.remediation.retry) {
const r = rescue.remediation;
// Sanitize cloud's fields — model sometimes still packs state
// into city ("Hammond, IN") even when prompted to split; guard
// by stripping comma+ from new_city and harvesting the trailing
// state if new_state is missing.
const cityRaw = (r.new_city ?? event.city).trim();
const cityParts = cityRaw.split(",").map(s => s.trim());
const cityClean = cityParts[0] || event.city;
const stateFromCity = cityParts[1]?.match(/^([A-Z]{2})/i)?.[1]?.toUpperCase() ?? "";
const newEvent: FillEvent = {
...event,
city: cityClean,
state: (r.new_state?.trim() || stateFromCity || event.state).toUpperCase(),
role: r.new_role ?? event.role,
count: r.new_count ?? event.count,
scenario_note: `[cloud-rescue ${rescue.duration_secs.toFixed(1)}s] ${r.rationale}`,
};
const noChange = newEvent.city === event.city
&& newEvent.role === event.role
&& newEvent.count === event.count;
if (noChange) {
console.log(` (cloud rescue declined — no actual pivot proposed)`);
} else {
console.log(` retry: ${event.role}×${event.count} ${event.city},${event.state} → ${newEvent.role}×${newEvent.count} ${newEvent.city},${newEvent.state}`);
const retryResult = await runEvent(newEvent, ctx);
// Annotate original result with the retry attempt so the
// retrospective can show both. Retry becomes THE result if it
// succeeded; otherwise original stays the primary outcome and
// retry is recorded alongside.
const originalForRecord = event;
const origTurnsDur = { turns: result.turns, duration_secs: result.duration_secs };
const retryAnnotation = {
retry_attempt: 1,
retry_remediation: {
proposed_city: r.new_city,
proposed_role: r.new_role,
proposed_count: r.new_count,
rationale: r.rationale,
cloud_model: OVERVIEW_MODEL,
cloud_duration_secs: rescue.duration_secs,
},
retry_result: {
event: retryResult.event,
ok: retryResult.ok,
fills: retryResult.fills,
turns: retryResult.turns,
duration_secs: retryResult.duration_secs,
error: retryResult.error,
gap_signals: retryResult.gap_signals,
sources_first_score: retryResult.sources_first_score,
sources_last_score: retryResult.sources_last_score,
pool_size: retryResult.pool_size,
playbook_citations: retryResult.playbook_citations,
discovered_pattern: retryResult.discovered_pattern,
},
original_event: originalForRecord,
};
if (retryResult.ok) {
// Promote retry to primary result — the coordinator cares
// about the final outcome. Keep original_event so the
// pivot is visible in the retrospective.
result = { ...retryResult, ...retryAnnotation, retry_attempt: 1 };
} else {
// Retry also failed. Keep original as primary, annotate
// with what was tried.
result = { ...result, ...retryAnnotation };
}
console.log(` retry outcome: ${retryResult.ok ? "✓ filled " + retryResult.fills.length + "/" + newEvent.count : "✗ " + (retryResult.error?.slice(0, 80) ?? "unknown")}`);
}
} else {
console.log(` (cloud rescue: ${rescue ? "retry=false" : "unavailable"})`);
}
}
ctx.results.push(result);
for (const s of result.gap_signals) {
const [category, ...rest] = s.split(":");
ctx.gap_signals.push({ event: event.at, category: category.trim(), detail: rest.join(":").trim() });
}
// Phase 24 — emit outcome to observer so its ERROR_ANALYZER and
// PLAYBOOK_BUILDER loops see scenario events alongside MCP ops.
// Best-effort; observer being down doesn't affect scenario flow.
postObserverEvent({
timestamp: new Date().toISOString(),
endpoint: "scenario:fill",
success: result.ok,
duration_ms: Math.round((result.duration_secs ?? 0) * 1000),
staffer_id: spec.staffer?.id,
sig_hash: computeSignature(spec),
event_kind: result.event.kind,
role: result.event.role,
city: result.event.city,
state: result.event.state,
count: result.event.count,
error: result.error,
rescue_attempted: !!result.retry_remediation,
rescue_succeeded: !!result.retry_remediation && result.ok,
output_summary: result.ok
? `filled ${result.fills.length}/${result.event.count} in ${result.turns} turns, ${result.playbook_citations?.length ?? 0} cites`
: `FAIL: ${(result.error ?? "unknown").slice(0, 80)}`,
}).catch(() => {});
// Option B — T3 checkpoint after every misplacement, and every N-th event.
const isLast = i === spec.events.length - 1;
const nthHit = T3_CHECKPOINT_EVERY > 0 && ((i + 1) % T3_CHECKPOINT_EVERY === 0);
const shouldCheckpoint = !T3_DISABLED && (event.kind === "misplacement" || nthHit || isLast);
const shouldCheckpoint = !ACTIVE_T3_DISABLED && (event.kind === "misplacement" || nthHit || isLast);
if (shouldCheckpoint) {
const cp = await runOverviewCheckpoint(event, result, ctx.results.slice(0, -1));
const cp = await runOverviewCheckpoint(event, result, ctx.results.slice(0, -1), spec.contract);
if (cp) {
checkpoints.push(cp);
await appendFile(join(out_dir, "checkpoints.jsonl"), JSON.stringify(cp) + "\n");
@@ -1166,7 +1674,7 @@ async function main() {
// Option A — T3 cross-day lesson. One final call distills the whole run.
// Saved to lesson.md and also seeded into playbook_memory so tomorrow's
// agent can retrieve it on similar setups.
if (!T3_DISABLED) {
if (!ACTIVE_T3_DISABLED) {
console.log(`\n▶ T3 cross-day lesson via ${OVERVIEW_MODEL}…`);
const tLesson = Date.now();
const lesson = await runCrossDayLesson(ctx, checkpoints);
@@ -1203,7 +1711,7 @@ async function main() {
events_ok: ctx.results.filter(r => r.ok).length,
checkpoint_count: checkpoints.length,
model: OVERVIEW_MODEL,
cloud: OVERVIEW_CLOUD,
cloud: ACTIVE_OVERVIEW_CLOUD,
lesson: lesson.trim(),
checkpoints: checkpoints.map(c => ({ after: c.after_event, risk: c.risk, hint: c.hint })),
created_at: new Date().toISOString(),
@@ -1219,6 +1727,35 @@ async function main() {
await writeRetrospective(ctx);
// Phase 22 KB — index this run + synthesize recommendation for next
// time this signature (or similar ones) show up. Event-driven cycle:
// run ends → KB updates → next run reads rec at startup.
try {
const elapsed = (Date.now() - runStart) / 1000;
const { sig_hash } = await indexRun(
out_dir,
{ client: spec.client, date: spec.date, events: spec.events, staffer: spec.staffer },
{
executor: ACTIVE_EXECUTOR,
reviewer: ACTIVE_REVIEWER,
overview: OVERVIEW_MODEL,
overview_cloud: ACTIVE_OVERVIEW_CLOUD,
},
elapsed,
);
console.log(`▶ KB indexed: sig=${sig_hash} (${elapsed.toFixed(1)}s)`);
const newRec = await recommendFor(spec, {
overview_model: OVERVIEW_MODEL,
cloud: ACTIVE_OVERVIEW_CLOUD,
k: 5,
});
if (newRec) {
console.log(`▶ KB recommendation written: confidence=${newRec.confidence} (${newRec.neighbors_consulted.length} neighbors consulted)`);
}
} catch (e) {
console.log(` (KB update skipped: ${(e as Error).message})`);
}
const okCount = ctx.results.filter(r => r.ok).length;
if (okCount < ctx.results.length) {
console.log(`\n⚠ ${okCount}/${ctx.results.length} events succeeded. See ${out_dir}/report.md for gaps.`);
+126
View File
@@ -0,0 +1,126 @@
{
"count": 20,
"seed": 1337,
"scenarios": [
{
"file": "scen_000_Great_Lakes_Mfg_Cincinnati.json",
"client": "Great Lakes Mfg",
"city": "Cincinnati",
"events": 4
},
{
"file": "scen_001_Parallel_Machining_Joliet.json",
"client": "Parallel Machining",
"city": "Joliet",
"events": 2
},
{
"file": "scen_002_Summit_Industrial_Cincinnati.json",
"client": "Summit Industrial",
"city": "Cincinnati",
"events": 3
},
{
"file": "scen_003_Pioneer_Assembly_Chicago.json",
"client": "Pioneer Assembly",
"city": "Chicago",
"events": 1
},
{
"file": "scen_004_Midway_Distribution_Columbus.json",
"client": "Midway Distribution",
"city": "Columbus",
"events": 2
},
{
"file": "scen_005_Apex_Warehouse_Cleveland.json",
"client": "Apex Warehouse",
"city": "Cleveland",
"events": 3
},
{
"file": "scen_006_Pioneer_Assembly_Flint.json",
"client": "Pioneer Assembly",
"city": "Flint",
"events": 5
},
{
"file": "scen_007_Riverfront_Steel_Toledo.json",
"client": "Riverfront Steel",
"city": "Toledo",
"events": 3
},
{
"file": "scen_008_Northland_Logistics_Indianapolis.json",
"client": "Northland Logistics",
"city": "Indianapolis",
"events": 4
},
{
"file": "scen_009_Parallel_Machining_Flint.json",
"client": "Parallel Machining",
"city": "Flint",
"events": 3
},
{
"file": "scen_010_Northland_Logistics_Chicago.json",
"client": "Northland Logistics",
"city": "Chicago",
"events": 2
},
{
"file": "scen_011_Heritage_Foods_Flint.json",
"client": "Heritage Foods",
"city": "Flint",
"events": 3
},
{
"file": "scen_012_Parallel_Machining_Kansas_City.json",
"client": "Parallel Machining",
"city": "Kansas City",
"events": 3
},
{
"file": "scen_013_Horizon_Supply_Flint.json",
"client": "Horizon Supply",
"city": "Flint",
"events": 3
},
{
"file": "scen_014_Midway_Distribution_Indianapolis.json",
"client": "Midway Distribution",
"city": "Indianapolis",
"events": 4
},
{
"file": "scen_015_Cornerstone_Fabrication_Kansas_City.json",
"client": "Cornerstone Fabrication",
"city": "Kansas City",
"events": 4
},
{
"file": "scen_016_Riverfront_Steel_Columbus.json",
"client": "Riverfront Steel",
"city": "Columbus",
"events": 4
},
{
"file": "scen_017_Summit_Industrial_Detroit.json",
"client": "Summit Industrial",
"city": "Detroit",
"events": 2
},
{
"file": "scen_018_Heritage_Foods_Cincinnati.json",
"client": "Heritage Foods",
"city": "Cincinnati",
"events": 4
},
{
"file": "scen_019_Midway_Distribution_Chicago.json",
"client": "Midway Distribution",
"city": "Chicago",
"events": 3
}
]
}
@@ -0,0 +1,66 @@
{
"client": "Riverline Logistics — Nashville Downtown Build-Out",
"date": "2026-05-05",
"contract": {
"deadline": "2026-05-19",
"budget_total_usd": 180000,
"budget_per_hour_max": 32,
"local_bonus_per_hour": 4,
"local_bonus_radius_mi": 75,
"fill_requirement": "paramount"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Welder",
"count": 4,
"city": "Nashville",
"state": "TN",
"shift_start": "07:00 AM",
"scenario_note": "Skilled welding for downtown structural assembly. Prefer local (in 75mi). Contract pays up to $32/hr + $4/hr local bonus. Fill is paramount — the contract is worth $180K total, broken = penalty."
},
{
"kind": "expansion",
"at": "08:30",
"role": "Packaging Operator",
"count": 6,
"city": "Nashville",
"state": "TN",
"shift_start": "08:30 AM",
"scenario_note": "Warehouse packaging on-site for the same project. 6 workers preferred Nashville-local; out-of-area acceptable if local pool is exhausted. Respect budget."
},
{
"kind": "baseline_fill",
"at": "09:00",
"role": "Shipping Clerk",
"count": 2,
"city": "Nashville",
"state": "TN",
"shift_start": "09:00 AM",
"scenario_note": "Administrative/shipping coordination role. Two clerks needed; tightening the load on packaging team."
},
{
"kind": "emergency",
"at": "13:00",
"role": "Welder",
"count": 2,
"city": "Nashville",
"state": "TN",
"shift_start": "13:00 PM",
"deadline": "15:00",
"scenario_note": "A subcontractor just dropped out — need 2 more Welders by 3pm. Use cloud rescue path if Nashville supply is exhausted; pivot to within-radius first (if any in 75mi), then budget-permitting out-of-area."
},
{
"kind": "misplacement",
"at": "15:30",
"role": "Packaging Operator",
"count": 1,
"city": "Nashville",
"state": "TN",
"shift_start": "15:30 PM",
"replaces_event": "08:30",
"scenario_note": "One packager no-showed. Single refill. Any Nashville-area candidate, budget already covered."
}
]
}
@@ -0,0 +1,43 @@
{
"client": "Great Lakes Mfg",
"date": "2026-04-21",
"events": [
{
"kind": "baseline_fill",
"at": "13:00",
"role": "Material Handler",
"count": 2,
"city": "Cincinnati",
"state": "OH",
"shift_start": "13:00 AM"
},
{
"kind": "recurring",
"at": "14:30",
"role": "Assembler",
"count": 2,
"city": "Cincinnati",
"state": "OH",
"shift_start": "14:30 AM"
},
{
"kind": "emergency",
"at": "10:30",
"role": "Material Handler",
"count": 3,
"city": "Cincinnati",
"state": "OH",
"shift_start": "10:30 AM"
},
{
"kind": "misplacement",
"at": "13:30",
"role": "Shipping Clerk",
"count": 1,
"city": "Cincinnati",
"state": "OH",
"shift_start": "13:30 AM",
"replaces_event": "13:00"
}
]
}
@@ -0,0 +1,24 @@
{
"client": "Parallel Machining",
"date": "2026-04-22",
"events": [
{
"kind": "baseline_fill",
"at": "10:00",
"role": "Machine Operator",
"count": 2,
"city": "Joliet",
"state": "IL",
"shift_start": "10:00 AM"
},
{
"kind": "recurring",
"at": "09:30",
"role": "Packer",
"count": 2,
"city": "Joliet",
"state": "IL",
"shift_start": "09:30 AM"
}
]
}
@@ -0,0 +1,33 @@
{
"client": "Summit Industrial",
"date": "2026-04-23",
"events": [
{
"kind": "baseline_fill",
"at": "13:30",
"role": "Shipping Clerk",
"count": 1,
"city": "Cincinnati",
"state": "OH",
"shift_start": "13:30 AM"
},
{
"kind": "recurring",
"at": "08:00",
"role": "Loader",
"count": 1,
"city": "Cincinnati",
"state": "OH",
"shift_start": "08:00 AM"
},
{
"kind": "emergency",
"at": "13:00",
"role": "Forklift Operator",
"count": 2,
"city": "Cincinnati",
"state": "OH",
"shift_start": "13:00 AM"
}
]
}
@@ -0,0 +1,15 @@
{
"client": "Pioneer Assembly",
"date": "2026-04-24",
"events": [
{
"kind": "baseline_fill",
"at": "14:00",
"role": "Receiving Clerk",
"count": 3,
"city": "Chicago",
"state": "IL",
"shift_start": "14:00 AM"
}
]
}
@@ -0,0 +1,24 @@
{
"client": "Midway Distribution",
"date": "2026-04-25",
"events": [
{
"kind": "baseline_fill",
"at": "09:30",
"role": "Forklift Operator",
"count": 1,
"city": "Columbus",
"state": "OH",
"shift_start": "09:30 AM"
},
{
"kind": "expansion",
"at": "13:00",
"role": "Assembler",
"count": 4,
"city": "Columbus",
"state": "OH",
"shift_start": "13:00 AM"
}
]
}
@@ -0,0 +1,33 @@
{
"client": "Apex Warehouse",
"date": "2026-04-26",
"events": [
{
"kind": "baseline_fill",
"at": "10:30",
"role": "Receiving Clerk",
"count": 1,
"city": "Cleveland",
"state": "OH",
"shift_start": "10:30 AM"
},
{
"kind": "recurring",
"at": "15:30",
"role": "Quality Tech",
"count": 2,
"city": "Cleveland",
"state": "OH",
"shift_start": "15:30 AM"
},
{
"kind": "expansion",
"at": "14:30",
"role": "Machine Operator",
"count": 5,
"city": "Cleveland",
"state": "OH",
"shift_start": "14:30 AM"
}
]
}
@@ -0,0 +1,52 @@
{
"client": "Pioneer Assembly",
"date": "2026-04-27",
"events": [
{
"kind": "baseline_fill",
"at": "13:30",
"role": "CNC Operator",
"count": 2,
"city": "Flint",
"state": "MI",
"shift_start": "13:30 AM"
},
{
"kind": "recurring",
"at": "11:30",
"role": "Loader",
"count": 1,
"city": "Flint",
"state": "MI",
"shift_start": "11:30 AM"
},
{
"kind": "expansion",
"at": "14:00",
"role": "Welder",
"count": 3,
"city": "Flint",
"state": "MI",
"shift_start": "14:00 AM"
},
{
"kind": "emergency",
"at": "12:30",
"role": "Machine Operator",
"count": 2,
"city": "Flint",
"state": "MI",
"shift_start": "12:30 AM"
},
{
"kind": "misplacement",
"at": "17:00",
"role": "Shipping Clerk",
"count": 1,
"city": "Flint",
"state": "MI",
"shift_start": "17:00 AM",
"replaces_event": "13:30"
}
]
}
@@ -0,0 +1,34 @@
{
"client": "Riverfront Steel",
"date": "2026-04-28",
"events": [
{
"kind": "baseline_fill",
"at": "11:00",
"role": "Assembler",
"count": 3,
"city": "Toledo",
"state": "OH",
"shift_start": "11:00 AM"
},
{
"kind": "recurring",
"at": "14:00",
"role": "Material Handler",
"count": 1,
"city": "Toledo",
"state": "OH",
"shift_start": "14:00 AM"
},
{
"kind": "misplacement",
"at": "08:30",
"role": "Packer",
"count": 1,
"city": "Toledo",
"state": "OH",
"shift_start": "08:30 AM",
"replaces_event": "11:00"
}
]
}
@@ -0,0 +1,43 @@
{
"client": "Northland Logistics",
"date": "2026-04-29",
"events": [
{
"kind": "baseline_fill",
"at": "14:00",
"role": "Quality Tech",
"count": 1,
"city": "Indianapolis",
"state": "IN",
"shift_start": "14:00 AM"
},
{
"kind": "recurring",
"at": "12:30",
"role": "Shipping Clerk",
"count": 1,
"city": "Indianapolis",
"state": "IN",
"shift_start": "12:30 AM"
},
{
"kind": "emergency",
"at": "10:00",
"role": "Welder",
"count": 2,
"city": "Indianapolis",
"state": "IN",
"shift_start": "10:00 AM"
},
{
"kind": "misplacement",
"at": "14:30",
"role": "Material Handler",
"count": 1,
"city": "Indianapolis",
"state": "IN",
"shift_start": "14:30 AM",
"replaces_event": "14:00"
}
]
}
@@ -0,0 +1,33 @@
{
"client": "Parallel Machining",
"date": "2026-04-30",
"events": [
{
"kind": "baseline_fill",
"at": "16:00",
"role": "Loader",
"count": 3,
"city": "Flint",
"state": "MI",
"shift_start": "16:00 AM"
},
{
"kind": "recurring",
"at": "09:30",
"role": "Warehouse Associate",
"count": 2,
"city": "Flint",
"state": "MI",
"shift_start": "09:30 AM"
},
{
"kind": "emergency",
"at": "12:30",
"role": "Packer",
"count": 3,
"city": "Flint",
"state": "MI",
"shift_start": "12:30 AM"
}
]
}
@@ -0,0 +1,24 @@
{
"client": "Northland Logistics",
"date": "2026-05-01",
"events": [
{
"kind": "baseline_fill",
"at": "15:00",
"role": "Shipping Clerk",
"count": 1,
"city": "Chicago",
"state": "IL",
"shift_start": "15:00 AM"
},
{
"kind": "expansion",
"at": "10:00",
"role": "Warehouse Associate",
"count": 2,
"city": "Chicago",
"state": "IL",
"shift_start": "10:00 AM"
}
]
}
@@ -0,0 +1,33 @@
{
"client": "Heritage Foods",
"date": "2026-05-02",
"events": [
{
"kind": "baseline_fill",
"at": "10:00",
"role": "Receiving Clerk",
"count": 3,
"city": "Flint",
"state": "MI",
"shift_start": "10:00 AM"
},
{
"kind": "expansion",
"at": "14:30",
"role": "Shipping Clerk",
"count": 4,
"city": "Flint",
"state": "MI",
"shift_start": "14:30 AM"
},
{
"kind": "emergency",
"at": "08:30",
"role": "Assembler",
"count": 3,
"city": "Flint",
"state": "MI",
"shift_start": "08:30 AM"
}
]
}
@@ -0,0 +1,33 @@
{
"client": "Parallel Machining",
"date": "2026-05-03",
"events": [
{
"kind": "baseline_fill",
"at": "08:00",
"role": "Warehouse Associate",
"count": 2,
"city": "Kansas City",
"state": "MO",
"shift_start": "08:00 AM"
},
{
"kind": "recurring",
"at": "11:30",
"role": "Material Handler",
"count": 2,
"city": "Kansas City",
"state": "MO",
"shift_start": "11:30 AM"
},
{
"kind": "expansion",
"at": "09:00",
"role": "Warehouse Associate",
"count": 5,
"city": "Kansas City",
"state": "MO",
"shift_start": "09:00 AM"
}
]
}
@@ -0,0 +1,33 @@
{
"client": "Horizon Supply",
"date": "2026-05-04",
"events": [
{
"kind": "baseline_fill",
"at": "11:00",
"role": "Forklift Operator",
"count": 1,
"city": "Flint",
"state": "MI",
"shift_start": "11:00 AM"
},
{
"kind": "recurring",
"at": "10:30",
"role": "CNC Operator",
"count": 1,
"city": "Flint",
"state": "MI",
"shift_start": "10:30 AM"
},
{
"kind": "emergency",
"at": "16:30",
"role": "Receiving Clerk",
"count": 3,
"city": "Flint",
"state": "MI",
"shift_start": "16:30 AM"
}
]
}
@@ -0,0 +1,42 @@
{
"client": "Midway Distribution",
"date": "2026-05-05",
"events": [
{
"kind": "baseline_fill",
"at": "16:00",
"role": "Assembler",
"count": 2,
"city": "Indianapolis",
"state": "IN",
"shift_start": "16:00 AM"
},
{
"kind": "recurring",
"at": "17:30",
"role": "Loader",
"count": 2,
"city": "Indianapolis",
"state": "IN",
"shift_start": "17:30 AM"
},
{
"kind": "expansion",
"at": "10:30",
"role": "Packer",
"count": 5,
"city": "Indianapolis",
"state": "IN",
"shift_start": "10:30 AM"
},
{
"kind": "emergency",
"at": "12:00",
"role": "Loader",
"count": 4,
"city": "Indianapolis",
"state": "IN",
"shift_start": "12:00 AM"
}
]
}
@@ -0,0 +1,43 @@
{
"client": "Cornerstone Fabrication",
"date": "2026-05-06",
"events": [
{
"kind": "baseline_fill",
"at": "14:00",
"role": "Warehouse Associate",
"count": 2,
"city": "Kansas City",
"state": "MO",
"shift_start": "14:00 AM"
},
{
"kind": "recurring",
"at": "17:30",
"role": "Loader",
"count": 1,
"city": "Kansas City",
"state": "MO",
"shift_start": "17:30 AM"
},
{
"kind": "emergency",
"at": "12:00",
"role": "Packer",
"count": 3,
"city": "Kansas City",
"state": "MO",
"shift_start": "12:00 AM"
},
{
"kind": "misplacement",
"at": "16:30",
"role": "Assembler",
"count": 1,
"city": "Kansas City",
"state": "MO",
"shift_start": "16:30 AM",
"replaces_event": "14:00"
}
]
}
@@ -0,0 +1,43 @@
{
"client": "Riverfront Steel",
"date": "2026-05-07",
"events": [
{
"kind": "baseline_fill",
"at": "12:00",
"role": "Quality Tech",
"count": 3,
"city": "Columbus",
"state": "OH",
"shift_start": "12:00 AM"
},
{
"kind": "recurring",
"at": "09:30",
"role": "Machine Operator",
"count": 1,
"city": "Columbus",
"state": "OH",
"shift_start": "09:30 AM"
},
{
"kind": "emergency",
"at": "17:30",
"role": "Assembler",
"count": 3,
"city": "Columbus",
"state": "OH",
"shift_start": "17:30 AM"
},
{
"kind": "misplacement",
"at": "15:30",
"role": "Material Handler",
"count": 1,
"city": "Columbus",
"state": "OH",
"shift_start": "15:30 AM",
"replaces_event": "12:00"
}
]
}
@@ -0,0 +1,24 @@
{
"client": "Summit Industrial",
"date": "2026-05-08",
"events": [
{
"kind": "baseline_fill",
"at": "13:00",
"role": "Warehouse Associate",
"count": 1,
"city": "Detroit",
"state": "MI",
"shift_start": "13:00 AM"
},
{
"kind": "recurring",
"at": "10:30",
"role": "Material Handler",
"count": 1,
"city": "Detroit",
"state": "MI",
"shift_start": "10:30 AM"
}
]
}
@@ -0,0 +1,43 @@
{
"client": "Heritage Foods",
"date": "2026-05-09",
"events": [
{
"kind": "baseline_fill",
"at": "12:00",
"role": "Assembler",
"count": 1,
"city": "Cincinnati",
"state": "OH",
"shift_start": "12:00 AM"
},
{
"kind": "recurring",
"at": "17:30",
"role": "Welder",
"count": 1,
"city": "Cincinnati",
"state": "OH",
"shift_start": "17:30 AM"
},
{
"kind": "emergency",
"at": "13:30",
"role": "Material Handler",
"count": 2,
"city": "Cincinnati",
"state": "OH",
"shift_start": "13:30 AM"
},
{
"kind": "misplacement",
"at": "12:00",
"role": "Receiving Clerk",
"count": 1,
"city": "Cincinnati",
"state": "OH",
"shift_start": "12:00 AM",
"replaces_event": "12:00"
}
]
}
@@ -0,0 +1,34 @@
{
"client": "Midway Distribution",
"date": "2026-05-10",
"events": [
{
"kind": "baseline_fill",
"at": "11:00",
"role": "Machine Operator",
"count": 2,
"city": "Chicago",
"state": "IL",
"shift_start": "11:00 AM"
},
{
"kind": "expansion",
"at": "17:00",
"role": "Shipping Clerk",
"count": 2,
"city": "Chicago",
"state": "IL",
"shift_start": "17:00 AM"
},
{
"kind": "misplacement",
"at": "09:30",
"role": "Packer",
"count": 1,
"city": "Chicago",
"state": "IL",
"shift_start": "09:30 AM",
"replaces_event": "11:00"
}
]
}
@@ -0,0 +1,73 @@
{
"client": "Pioneer Assembly — Indianapolis Plant Expansion",
"date": "2026-04-24",
"contract": {
"deadline": "2026-05-26",
"budget_total_usd": 220000,
"budget_per_hour_max": 30,
"local_bonus_per_hour": 5,
"local_bonus_radius_mi": 60,
"fill_requirement": "paramount"
},
"staffer": {
"id": "S-001",
"name": "Maria Chen",
"tenure_months": 48,
"role": "senior",
"tool_level": "full"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:30",
"role": "Assembler",
"count": 6,
"city": "Indianapolis",
"state": "IN",
"shift_start": "07:30 AM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "recurring",
"at": "09:30",
"role": "Quality Tech",
"count": 2,
"city": "Indianapolis",
"state": "IN",
"shift_start": "09:30 AM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "expansion",
"at": "11:00",
"role": "Machine Operator",
"count": 5,
"city": "Indianapolis",
"state": "IN",
"shift_start": "11:00 PM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "emergency",
"at": "14:00",
"role": "Machine Operator",
"count": 3,
"deadline": "16:00",
"city": "Indianapolis",
"state": "IN",
"shift_start": "14:00 PM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "misplacement",
"at": "16:00",
"role": "Assembler",
"count": 1,
"replaces_event": "07:30",
"city": "Indianapolis",
"state": "IN",
"shift_start": "16:00 PM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-26, fill=paramount."
}
]
}
@@ -0,0 +1,62 @@
{
"client": "Midway Distribution — Joliet DC Ramp",
"date": "2026-04-23",
"contract": {
"deadline": "2026-05-12",
"budget_total_usd": 120000,
"budget_per_hour_max": 28,
"local_bonus_per_hour": 3,
"local_bonus_radius_mi": 50,
"fill_requirement": "preferred"
},
"staffer": {
"id": "S-001",
"name": "Maria Chen",
"tenure_months": 48,
"role": "senior",
"tool_level": "full"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Warehouse Associate",
"count": 5,
"city": "Joliet",
"state": "IL",
"shift_start": "07:00 AM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "recurring",
"at": "10:00",
"role": "Forklift Operator",
"count": 3,
"city": "Joliet",
"state": "IL",
"shift_start": "10:00 PM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "expansion",
"at": "12:30",
"role": "Picker",
"count": 4,
"city": "Joliet",
"state": "IL",
"shift_start": "12:30 PM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "misplacement",
"at": "15:00",
"role": "Forklift Operator",
"count": 1,
"replaces_event": "10:00",
"city": "Joliet",
"state": "IL",
"shift_start": "15:00 PM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-12, fill=preferred."
}
]
}
@@ -0,0 +1,73 @@
{
"client": "Riverline Logistics — Nashville Downtown Build-Out",
"date": "2026-04-22",
"contract": {
"deadline": "2026-05-19",
"budget_total_usd": 180000,
"budget_per_hour_max": 32,
"local_bonus_per_hour": 4,
"local_bonus_radius_mi": 75,
"fill_requirement": "paramount"
},
"staffer": {
"id": "S-001",
"name": "Maria Chen",
"tenure_months": 48,
"role": "senior",
"tool_level": "full"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Welder",
"count": 4,
"city": "Nashville",
"state": "TN",
"shift_start": "07:00 AM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "expansion",
"at": "08:30",
"role": "Packaging Operator",
"count": 6,
"city": "Nashville",
"state": "TN",
"shift_start": "08:30 AM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "baseline_fill",
"at": "09:00",
"role": "Shipping Clerk",
"count": 2,
"city": "Nashville",
"state": "TN",
"shift_start": "09:00 AM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "emergency",
"at": "13:00",
"role": "Welder",
"count": 2,
"deadline": "15:00",
"city": "Nashville",
"state": "TN",
"shift_start": "13:00 PM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "misplacement",
"at": "15:30",
"role": "Packaging Operator",
"count": 1,
"replaces_event": "08:30",
"city": "Nashville",
"state": "TN",
"shift_start": "15:30 PM",
"scenario_note": "Staffed by Maria Chen (senior, 48mo). Contract deadline 2026-05-19, fill=paramount."
}
]
}
@@ -0,0 +1,73 @@
{
"client": "Pioneer Assembly — Indianapolis Plant Expansion",
"date": "2026-04-27",
"contract": {
"deadline": "2026-05-26",
"budget_total_usd": 220000,
"budget_per_hour_max": 30,
"local_bonus_per_hour": 5,
"local_bonus_radius_mi": 60,
"fill_requirement": "paramount"
},
"staffer": {
"id": "S-002",
"name": "James Park",
"tenure_months": 14,
"role": "mid",
"tool_level": "local"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:30",
"role": "Assembler",
"count": 6,
"city": "Indianapolis",
"state": "IN",
"shift_start": "07:30 AM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "recurring",
"at": "09:30",
"role": "Quality Tech",
"count": 2,
"city": "Indianapolis",
"state": "IN",
"shift_start": "09:30 AM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "expansion",
"at": "11:00",
"role": "Machine Operator",
"count": 5,
"city": "Indianapolis",
"state": "IN",
"shift_start": "11:00 PM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "emergency",
"at": "14:00",
"role": "Machine Operator",
"count": 3,
"deadline": "16:00",
"city": "Indianapolis",
"state": "IN",
"shift_start": "14:00 PM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "misplacement",
"at": "16:00",
"role": "Assembler",
"count": 1,
"replaces_event": "07:30",
"city": "Indianapolis",
"state": "IN",
"shift_start": "16:00 PM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-26, fill=paramount."
}
]
}
@@ -0,0 +1,62 @@
{
"client": "Midway Distribution — Joliet DC Ramp",
"date": "2026-04-26",
"contract": {
"deadline": "2026-05-12",
"budget_total_usd": 120000,
"budget_per_hour_max": 28,
"local_bonus_per_hour": 3,
"local_bonus_radius_mi": 50,
"fill_requirement": "preferred"
},
"staffer": {
"id": "S-002",
"name": "James Park",
"tenure_months": 14,
"role": "mid",
"tool_level": "local"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Warehouse Associate",
"count": 5,
"city": "Joliet",
"state": "IL",
"shift_start": "07:00 AM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "recurring",
"at": "10:00",
"role": "Forklift Operator",
"count": 3,
"city": "Joliet",
"state": "IL",
"shift_start": "10:00 PM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "expansion",
"at": "12:30",
"role": "Picker",
"count": 4,
"city": "Joliet",
"state": "IL",
"shift_start": "12:30 PM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "misplacement",
"at": "15:00",
"role": "Forklift Operator",
"count": 1,
"replaces_event": "10:00",
"city": "Joliet",
"state": "IL",
"shift_start": "15:00 PM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-12, fill=preferred."
}
]
}
@@ -0,0 +1,73 @@
{
"client": "Riverline Logistics — Nashville Downtown Build-Out",
"date": "2026-04-25",
"contract": {
"deadline": "2026-05-19",
"budget_total_usd": 180000,
"budget_per_hour_max": 32,
"local_bonus_per_hour": 4,
"local_bonus_radius_mi": 75,
"fill_requirement": "paramount"
},
"staffer": {
"id": "S-002",
"name": "James Park",
"tenure_months": 14,
"role": "mid",
"tool_level": "local"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Welder",
"count": 4,
"city": "Nashville",
"state": "TN",
"shift_start": "07:00 AM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "expansion",
"at": "08:30",
"role": "Packaging Operator",
"count": 6,
"city": "Nashville",
"state": "TN",
"shift_start": "08:30 AM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "baseline_fill",
"at": "09:00",
"role": "Shipping Clerk",
"count": 2,
"city": "Nashville",
"state": "TN",
"shift_start": "09:00 AM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "emergency",
"at": "13:00",
"role": "Welder",
"count": 2,
"deadline": "15:00",
"city": "Nashville",
"state": "TN",
"shift_start": "13:00 PM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "misplacement",
"at": "15:30",
"role": "Packaging Operator",
"count": 1,
"replaces_event": "08:30",
"city": "Nashville",
"state": "TN",
"shift_start": "15:30 PM",
"scenario_note": "Staffed by James Park (mid, 14mo). Contract deadline 2026-05-19, fill=paramount."
}
]
}
@@ -0,0 +1,73 @@
{
"client": "Pioneer Assembly — Indianapolis Plant Expansion",
"date": "2026-04-30",
"contract": {
"deadline": "2026-05-26",
"budget_total_usd": 220000,
"budget_per_hour_max": 30,
"local_bonus_per_hour": 5,
"local_bonus_radius_mi": 60,
"fill_requirement": "paramount"
},
"staffer": {
"id": "S-003",
"name": "Sam Torres",
"tenure_months": 4,
"role": "junior",
"tool_level": "basic"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:30",
"role": "Assembler",
"count": 6,
"city": "Indianapolis",
"state": "IN",
"shift_start": "07:30 AM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "recurring",
"at": "09:30",
"role": "Quality Tech",
"count": 2,
"city": "Indianapolis",
"state": "IN",
"shift_start": "09:30 AM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "expansion",
"at": "11:00",
"role": "Machine Operator",
"count": 5,
"city": "Indianapolis",
"state": "IN",
"shift_start": "11:00 PM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "emergency",
"at": "14:00",
"role": "Machine Operator",
"count": 3,
"deadline": "16:00",
"city": "Indianapolis",
"state": "IN",
"shift_start": "14:00 PM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "misplacement",
"at": "16:00",
"role": "Assembler",
"count": 1,
"replaces_event": "07:30",
"city": "Indianapolis",
"state": "IN",
"shift_start": "16:00 PM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-26, fill=paramount."
}
]
}
@@ -0,0 +1,62 @@
{
"client": "Midway Distribution — Joliet DC Ramp",
"date": "2026-04-29",
"contract": {
"deadline": "2026-05-12",
"budget_total_usd": 120000,
"budget_per_hour_max": 28,
"local_bonus_per_hour": 3,
"local_bonus_radius_mi": 50,
"fill_requirement": "preferred"
},
"staffer": {
"id": "S-003",
"name": "Sam Torres",
"tenure_months": 4,
"role": "junior",
"tool_level": "basic"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Warehouse Associate",
"count": 5,
"city": "Joliet",
"state": "IL",
"shift_start": "07:00 AM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "recurring",
"at": "10:00",
"role": "Forklift Operator",
"count": 3,
"city": "Joliet",
"state": "IL",
"shift_start": "10:00 PM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "expansion",
"at": "12:30",
"role": "Picker",
"count": 4,
"city": "Joliet",
"state": "IL",
"shift_start": "12:30 PM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "misplacement",
"at": "15:00",
"role": "Forklift Operator",
"count": 1,
"replaces_event": "10:00",
"city": "Joliet",
"state": "IL",
"shift_start": "15:00 PM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-12, fill=preferred."
}
]
}
@@ -0,0 +1,73 @@
{
"client": "Riverline Logistics — Nashville Downtown Build-Out",
"date": "2026-04-28",
"contract": {
"deadline": "2026-05-19",
"budget_total_usd": 180000,
"budget_per_hour_max": 32,
"local_bonus_per_hour": 4,
"local_bonus_radius_mi": 75,
"fill_requirement": "paramount"
},
"staffer": {
"id": "S-003",
"name": "Sam Torres",
"tenure_months": 4,
"role": "junior",
"tool_level": "basic"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Welder",
"count": 4,
"city": "Nashville",
"state": "TN",
"shift_start": "07:00 AM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "expansion",
"at": "08:30",
"role": "Packaging Operator",
"count": 6,
"city": "Nashville",
"state": "TN",
"shift_start": "08:30 AM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "baseline_fill",
"at": "09:00",
"role": "Shipping Clerk",
"count": 2,
"city": "Nashville",
"state": "TN",
"shift_start": "09:00 AM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "emergency",
"at": "13:00",
"role": "Welder",
"count": 2,
"deadline": "15:00",
"city": "Nashville",
"state": "TN",
"shift_start": "13:00 PM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "misplacement",
"at": "15:30",
"role": "Packaging Operator",
"count": 1,
"replaces_event": "08:30",
"city": "Nashville",
"state": "TN",
"shift_start": "15:30 PM",
"scenario_note": "Staffed by Sam Torres (junior, 4mo). Contract deadline 2026-05-19, fill=paramount."
}
]
}
@@ -0,0 +1,73 @@
{
"client": "Pioneer Assembly — Indianapolis Plant Expansion",
"date": "2026-05-03",
"contract": {
"deadline": "2026-05-26",
"budget_total_usd": 220000,
"budget_per_hour_max": 30,
"local_bonus_per_hour": 5,
"local_bonus_radius_mi": 60,
"fill_requirement": "paramount"
},
"staffer": {
"id": "S-004",
"name": "Alex Rivera",
"tenure_months": 1,
"role": "trainee",
"tool_level": "minimal"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:30",
"role": "Assembler",
"count": 6,
"city": "Indianapolis",
"state": "IN",
"shift_start": "07:30 AM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "recurring",
"at": "09:30",
"role": "Quality Tech",
"count": 2,
"city": "Indianapolis",
"state": "IN",
"shift_start": "09:30 AM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "expansion",
"at": "11:00",
"role": "Machine Operator",
"count": 5,
"city": "Indianapolis",
"state": "IN",
"shift_start": "11:00 PM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "emergency",
"at": "14:00",
"role": "Machine Operator",
"count": 3,
"deadline": "16:00",
"city": "Indianapolis",
"state": "IN",
"shift_start": "14:00 PM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
},
{
"kind": "misplacement",
"at": "16:00",
"role": "Assembler",
"count": 1,
"replaces_event": "07:30",
"city": "Indianapolis",
"state": "IN",
"shift_start": "16:00 PM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-26, fill=paramount."
}
]
}
@@ -0,0 +1,62 @@
{
"client": "Midway Distribution — Joliet DC Ramp",
"date": "2026-05-02",
"contract": {
"deadline": "2026-05-12",
"budget_total_usd": 120000,
"budget_per_hour_max": 28,
"local_bonus_per_hour": 3,
"local_bonus_radius_mi": 50,
"fill_requirement": "preferred"
},
"staffer": {
"id": "S-004",
"name": "Alex Rivera",
"tenure_months": 1,
"role": "trainee",
"tool_level": "minimal"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Warehouse Associate",
"count": 5,
"city": "Joliet",
"state": "IL",
"shift_start": "07:00 AM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "recurring",
"at": "10:00",
"role": "Forklift Operator",
"count": 3,
"city": "Joliet",
"state": "IL",
"shift_start": "10:00 PM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "expansion",
"at": "12:30",
"role": "Picker",
"count": 4,
"city": "Joliet",
"state": "IL",
"shift_start": "12:30 PM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-12, fill=preferred."
},
{
"kind": "misplacement",
"at": "15:00",
"role": "Forklift Operator",
"count": 1,
"replaces_event": "10:00",
"city": "Joliet",
"state": "IL",
"shift_start": "15:00 PM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-12, fill=preferred."
}
]
}
@@ -0,0 +1,73 @@
{
"client": "Riverline Logistics — Nashville Downtown Build-Out",
"date": "2026-05-01",
"contract": {
"deadline": "2026-05-19",
"budget_total_usd": 180000,
"budget_per_hour_max": 32,
"local_bonus_per_hour": 4,
"local_bonus_radius_mi": 75,
"fill_requirement": "paramount"
},
"staffer": {
"id": "S-004",
"name": "Alex Rivera",
"tenure_months": 1,
"role": "trainee",
"tool_level": "minimal"
},
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Welder",
"count": 4,
"city": "Nashville",
"state": "TN",
"shift_start": "07:00 AM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "expansion",
"at": "08:30",
"role": "Packaging Operator",
"count": 6,
"city": "Nashville",
"state": "TN",
"shift_start": "08:30 AM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "baseline_fill",
"at": "09:00",
"role": "Shipping Clerk",
"count": 2,
"city": "Nashville",
"state": "TN",
"shift_start": "09:00 AM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "emergency",
"at": "13:00",
"role": "Welder",
"count": 2,
"deadline": "15:00",
"city": "Nashville",
"state": "TN",
"shift_start": "13:00 PM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
},
{
"kind": "misplacement",
"at": "15:30",
"role": "Packaging Operator",
"count": 1,
"replaces_event": "08:30",
"city": "Nashville",
"state": "TN",
"shift_start": "15:30 PM",
"scenario_note": "Staffed by Alex Rivera (trainee, 1mo). Contract deadline 2026-05-19, fill=paramount."
}
]
}
@@ -0,0 +1,77 @@
{
"count": 12,
"scenarios": [
{
"file": "S-001_nashville_downtown.json",
"staffer": "Maria Chen",
"contract": "nashville_downtown",
"client": "Riverline Logistics — Nashville Downtown Build-Out"
},
{
"file": "S-001_joliet_warehouse.json",
"staffer": "Maria Chen",
"contract": "joliet_warehouse",
"client": "Midway Distribution — Joliet DC Ramp"
},
{
"file": "S-001_indianapolis_assembly.json",
"staffer": "Maria Chen",
"contract": "indianapolis_assembly",
"client": "Pioneer Assembly — Indianapolis Plant Expansion"
},
{
"file": "S-002_nashville_downtown.json",
"staffer": "James Park",
"contract": "nashville_downtown",
"client": "Riverline Logistics — Nashville Downtown Build-Out"
},
{
"file": "S-002_joliet_warehouse.json",
"staffer": "James Park",
"contract": "joliet_warehouse",
"client": "Midway Distribution — Joliet DC Ramp"
},
{
"file": "S-002_indianapolis_assembly.json",
"staffer": "James Park",
"contract": "indianapolis_assembly",
"client": "Pioneer Assembly — Indianapolis Plant Expansion"
},
{
"file": "S-003_nashville_downtown.json",
"staffer": "Sam Torres",
"contract": "nashville_downtown",
"client": "Riverline Logistics — Nashville Downtown Build-Out"
},
{
"file": "S-003_joliet_warehouse.json",
"staffer": "Sam Torres",
"contract": "joliet_warehouse",
"client": "Midway Distribution — Joliet DC Ramp"
},
{
"file": "S-003_indianapolis_assembly.json",
"staffer": "Sam Torres",
"contract": "indianapolis_assembly",
"client": "Pioneer Assembly — Indianapolis Plant Expansion"
},
{
"file": "S-004_nashville_downtown.json",
"staffer": "Alex Rivera",
"contract": "nashville_downtown",
"client": "Riverline Logistics — Nashville Downtown Build-Out"
},
{
"file": "S-004_joliet_warehouse.json",
"staffer": "Alex Rivera",
"contract": "joliet_warehouse",
"client": "Midway Distribution — Joliet DC Ramp"
},
{
"file": "S-004_indianapolis_assembly.json",
"staffer": "Alex Rivera",
"contract": "indianapolis_assembly",
"client": "Pioneer Assembly — Indianapolis Plant Expansion"
}
]
}
@@ -0,0 +1,58 @@
{
"client": "Ironclad Industrial",
"date": "2026-04-22",
"events": [
{
"kind": "baseline_fill",
"at": "07:00",
"role": "Electrician",
"count": 5,
"city": "Gary",
"state": "IN",
"shift_start": "07:00 AM",
"scenario_note": "Gary IN has ZERO Electricians in the index. Local WILL fail this. Cloud should diagnose no-supply and recommend pivoting to Chicago IL (40min drive) or relaxing to 'Maintenance Tech'."
},
{
"kind": "expansion",
"at": "09:30",
"role": "Safety Coordinator",
"count": 8,
"city": "Peoria",
"state": "IL",
"shift_start": "09:30 AM",
"scenario_note": "Safety Coordinator is the rarest role overall (~4500 nationally). 8× in a mid-sized city with availability > 0.5 is genuinely tight. Cloud should either confirm or suggest multi-city sourcing."
},
{
"kind": "emergency",
"at": "11:45",
"role": "Welder",
"count": 3,
"city": "Flint",
"state": "MI",
"shift_start": "12:00 PM",
"deadline": "13:30",
"scenario_note": "Flint MI has ZERO workers indexed — total data desert. Cloud must flag 'impossible supply' and recommend pivot (Detroit 60mi, Saginaw 40mi)."
},
{
"kind": "expansion",
"at": "14:00",
"role": "Tool & Die Maker",
"count": 4,
"city": "Grand Rapids",
"state": "MI",
"shift_start": "14:00 PM",
"scenario_note": "Tool & Die Maker is scarce (~9000 total). 4× in Grand Rapids, availability > 0.5 AND reliability > 0.75. Tight but solvable if playbook_memory has history; cloud should prioritize proven performers."
},
{
"kind": "misplacement",
"at": "15:30",
"role": "Electrician",
"count": 1,
"city": "Gary",
"state": "IN",
"shift_start": "15:30 PM",
"replaces_event": "07:00",
"scenario_note": "Refilling 1× Electrician in Gary after a no-show. Same data desert as event 1 — cloud should recognize the repeat and recommend the SAME pivot it gave earlier, proving it learns within-run."
}
]
}