Brings in the dev-side prose / bib / math fixes that landed since the
yaml-audit branch was cut, and resolves three small conflicts:
* interviews/vault-cli/scripts/archive/split_corpus.py
origin/dev deleted it (archive cleanup); we honor the deletion.
* interviews/vault-cli/scripts/validate_drafts.py
origin/dev removed a leftover no-op statement; took theirs.
* interviews/vault-cli/scripts/summarize_proposed_chains.py
origin/dev renamed loop var lvl→level; took theirs.
The two protected qmds (data_selection.qmd, model_compression.qmd)
are temp-stashed before the merge to honor the 'do not touch' rule;
restored after the merge commit lands.
After this commit, yaml-audit contains every commit on origin/dev as
an ancestor, so dev can fast-forward to yaml-audit's tip when the
maintainer is ready to merge.
The /contribute page's topic datalist mapped allTopics with key={t.id},
but topic ids appear in multiple competency areas (54 topics shared
across 2-11 areas, e.g. 'mlops-lifecycle' spans 11 areas). Each
duplicate triggered the React 'two children with the same key' warning
— 326 of them per page load.
Fix: namespace the key by area, key={`${t.area}::${t.id}`}. The
'value' attribute stays as t.id since that's what the user picks.
Verified by walkthrough script: /contribute now renders with zero
console errors, like the other 18 routes.
Three small renderer fixes that came out of inspecting how the
audit-corrected YAML content lands on /practice/?q=...:
1. Strip the redundant 'Conclusion & Interpretation:' / 'Result:'
prefixes from result steps. The green callout already signals
'this is the conclusion'; leaving the labels in produces noise
like 'Conclusion & Interpretation: Result: Memory-Bound. ...'.
Handles bold, unbold, and bold-wrapping-the-whole-phrase forms.
2. Teach the number-and-unit highlighter about scientific notation
(Ne12, 1.2×10^14) so phrases like '120e12 FLOPs' render as a
single number+unit chunk instead of '120' (bold) + 'e12' (plain)
+ 'FLOPs' (gray). Also broaden the unit vocabulary to include
Hz/MHz/GHz, W/mW/μW/mJ/μJ/J, MACs, cycles, frames, samples, and
common compound rates (FLOPs/byte, FLOP/cycle, etc.).
3. Distinguish a *section header* line ('**Conclusion & Interpretation:**'
alone on its line) from a *result* line. Previously the parser
marked the header as isResult=true, which then rendered an empty
green callout because cleanStepText stripped the header to ''.
Filter empty steps after cleaning as a belt-and-braces.
Verified across 10 sample questions covering different tracks
(cloud/edge/mobile/tinyml) and napkin-math shapes (sci notation,
multi-section structured, quantization-with-code, compute-bound,
memory-bound, I/O-bound). No regressions; the result blocks now
read directly with the verdict, not the section label.
Add interviews/staffml/README.md covering the local development
workflow that the prior commit's predev hook relies on:
- TL;DR install + run-dev steps
- explanation of the production-worker vs local-static data flow
- what the predev hook does (sync-periodic-table + vault build --local)
- env vars (NEXT_PUBLIC_VAULT_FALLBACK, NEXT_PUBLIC_VAULT_API,
STAFFML_SKIP_LOCAL_CORPUS) and their effects
- troubleshooting the three failure modes that bit us during the YAML
audit work (could-not-load, stale content, infinite loading)
Update interviews/vault-cli/README.md to surface `vault build --local`
in the Local-dev section with a pointer to the StaffML README.
The intent: a contributor who edits a YAML and doesn't see the change
in the dev server should now find the answer in the README before
they're forced to read the loader source.
Before this change, the StaffML Next.js dev server fetched scenario and
details (including napkin_math) from the production Cloudflare Worker
even when contributors had local YAML edits — so changes weren't visible
without shipping. The opt-in static-fallback path existed but was wired
incorrectly: getStaticFullDetail used a Function-constructor dynamic
import of ../data/corpus.json, which Turbopack rewrote to a non-existent
/_next/static/data/corpus.json URL and 404'd at runtime.
Fix in three parts:
1. Loader (interviews/staffml/src/lib/corpus.ts): replace the broken
dynamic import with fetch('/data/corpus.json'). On failure, throw a
clear error pointing at `vault build --local`.
2. Build (interviews/vault-cli/src/vault_cli/commands/build.py): mirror
the generated corpus.json into interviews/staffml/public/data/ so
Next serves it as a static asset. Add --local as a clearer alias for
--local-json and update the help text to spell out the dev workflow.
3. Wiring (interviews/staffml/package.json + scripts/build-local-corpus.mjs):
predev now runs `vault build --local` automatically, with a soft-fail
path if the vault CLI isn't installed (so first-time contributors
still get a working dev server, just with the worker fallback). The
committed .env.development sets NEXT_PUBLIC_VAULT_FALLBACK=static so
the static path is the default in dev. Both copies of corpus.json are
gitignored as build artifacts (the YAMLs are the source of truth).
lucide-react v1.0 removed all brand icons (Github, Twitter, Facebook,
etc.) for trademark reasons, so the bundled Github symbol is no longer
exported. Add a local GithubIcon component using the standard GitHub
mark, bump lucide-react to ^1.14.0, and update the four consumers.
Closes#1667.
Sync the yaml-audit branch with the latest dev work since the previous
sync (5c5af75ed). Brings in 73 commits including:
- CI security fixes: postcss XSS bump, uuid bounds bump, codeql
paths-ignore for vendored bundles, read-only token on
staffml-validate-vault workflow
- kits/ dark mode polish: code-block readability, dropdown contrast
- vault-cli/: pre-commit ruff hook + 20 ruff fixes, all-contributors
auto-credit workflow change to pull_request_target
- dev's earlier merge of yaml-audit (836d481b5) carrying the
pre-trailer-strip Phase 1/2/3 history; this merge harmonises that
with the current trailer-clean yaml-audit tip
- misc bug fixes (tinytorch perceptron seed, infra workflows,
socratiq vite dev injector)
Conflicts resolved (if any) preserve the yaml-audit-side authoritative
state for vault/* files (we own those) and the dev-side authoritative
state for .github/workflows/* and other shared infrastructure.
# Conflicts:
# .github/workflows/all-contributors-auto-credit.yml
# .github/workflows/staffml-preview-dev.yml
# interviews/staffml/src/data/corpus-summary.json
# interviews/staffml/src/data/vault-manifest.json
# interviews/staffml/tests/chain-and-vault-smoke.mjs
# interviews/vault-cli/README.md
# interviews/vault-cli/docs/CHAIN_ROADMAP.md
# interviews/vault-cli/scripts/build_chains_with_gemini.py
# interviews/vault-cli/scripts/generate_question_for_gap.py
# interviews/vault-cli/scripts/merge_chain_passes.py
# interviews/vault-cli/scripts/validate_drafts.py
# interviews/vault-cli/src/vault_cli/legacy_export.py
# interviews/vault-cli/tests/test_chain_validation.py
# interviews/vault/.gitignore
# interviews/vault/ARCHITECTURE.md
# interviews/vault/chains.json
# interviews/vault/id-registry.yaml
# interviews/vault/questions/edge/optimization/edge-2536.yaml
# interviews/vault/questions/mobile/deployment/mobile-2147.yaml
# tinytorch/src/03_layers/03_layers.py
After publishing mobile-2147 and edge-2536 in 9ab6bb85d (Phase 3.d
disposition), re-ran the strict-mode chain build on the two affected
buckets to absorb them into proper progressions.
Targeted rebuild (2 Gemini calls, ~1 min wall time vs ~25 min for
build_chains_with_gemini.py --all):
build_chains_with_gemini.py --bucket mobile:model-format-conversion
build_chains_with_gemini.py --bucket edge:pruning-sparsity
Results:
mobile/model-format-conversion: 2 secondary chains → 12 primary chains.
Notable: mobile-2147 lands in a clean L1→L2→L3→L4→L5→L6+ chain
(mobile-0984 → mobile-2147 → mobile-1022 → mobile-1511 → mobile-0980
→ mobile-1662) — exactly the strict +1 progression the bridge was
authored to enable.
edge/pruning-sparsity: 3 secondary chains → 4 primary chains.
Notable: edge-2536 lands in L1→L3→L4→L5 (edge-1784 → edge-1960 →
edge-2536 → edge-1957) — slots between edge-1960 (L3) and edge-1957
(L5) as designed, turning a Δ=2 jump into Δ=1 + Δ=1.
Both buckets transition from secondary-only to primary-only — strict
mode produced clean +1/+2 chains with the new bridges in place.
Net chain count: 824 → 835 (-5 old secondary, +16 new primary).
Validation:
apply_proposed_chains.py --dry-run on merged chains.json: clean
vault check --strict: 10,703 loaded, 0 failures
vault build --local-json: chainCount=835, releaseHash 9b381a55…
Acting on the audit findings (independent Gemini audit, 2 runs converged
on the same per-draft verdicts). Of the 5 drafts in the Phase 3 pilot:
Published (status: published, human_reviewed: verified):
mobile-2147 Model Format Conversion: Sizing the FP16 CoreML Payload
Clean L2 / understand. FP32→FP16 storage halving on a
15M-param iOS model. Realistic App Store framing,
correct math, no fabrication.
edge-2536 Diagnosing Zero Latency Gains from Unstructured Pruning
on Coral TPU
Canonical L4 / analyze lesson on dense systolic arrays
+ unstructured sparsity. Edited the scenario's baseline
latency from 80ms → 15ms (more realistic for MobileNetV2
on Coral USB TPU; audit flagged the 80ms figure as
unrealistic). Pedagogical content unchanged.
Rejected (deleted):
edge-2537 edge/tco-cost-modeling
Audit (both runs) flagged "cognitive load too low for L3
— basic arithmetic word problem with all parameters
given". Real L3 TCO questions require judgement under
uncertainty; this one is L1/L2.
mobile-2146 mobile/duty-cycling
Audit flagged a physically absurd 0.5s wake-up at 4W for
a mobile NPU (real NPUs wake in milliseconds). Run 2
additionally flagged the dashcam framing as broken (a
dashcam idle 75% of the time would miss accidents).
Premise is fiction; the lesson can't be salvaged.
edge-2535 edge/latency-decomposition
Failed validate_drafts.py originality gate at promotion
(cosine 0.933 vs its own bridge anchor edge-1883). Was
left as .yaml.draft pending review; content is fine on
its own, but pedagogically duplicative with the lesson
in the now-promoted edge-2536 (host-side bottleneck on
Coral). Cleaner to drop than de-duplicate.
The 4 ID entries in id-registry.yaml stay (append-only ledger); the
removed YAMLs become dangling registry entries which is the intended
behaviour — the registry is "every ID ever assigned", not "every ID
currently active".
Validation:
vault check --strict: 10,703 loaded, 0 invariant failures
vault build --local-json: 9440 published (was 9438 + 2), chainCount=824,
releaseHash a9a601c2bf… (was 479811040b…)
Action on the strongest finding from the 2026-05-01 independent audit:
54 of 55 Δ=0 chains had no shared scenario (the "two questions
sharing a scenario thread" constraint the lenient prompt was supposed
to enforce). Two independent audit fields agreed (verdict=bad and
shared_scenario=no), so this isn't a tuning question — the design
choice was wrong.
Why remove Δ=0 entirely rather than tighten the prompt:
- The chain definition is "pedagogical progression through Bloom
levels"; same-level edges contradict the definition.
- The "shared scenario / different angle" carve-out is unenforceable
by an LLM at corpus scale (audit confirmed).
- Same-scenario same-level pairs are more honestly modeled as
siblings of a chain anchor, not as chain members.
Changes:
- chains.json: 879 → 824. Dropped: 55 chains (all tier=secondary,
since Δ=0 was only ever produced by the lenient sweep).
Per-track: edge -19, tinyml -12, mobile -10, cloud -7, global -7.
- build_chains_with_gemini.py:
MODE_CONFIG["lenient"]["allowed_deltas"]: {0,1,2,3} → {1,2,3}
LENIENT_PROMPT_TEMPLATE: Δ=0 paragraph rewritten to explicitly
REJECT same-level pairs (with rationale citing the audit).
docstring + --mode help text updated.
- tests/test_chain_validation.py:
test_lenient_accepts_same_level_pair → test_lenient_rejects_same_level_pair
header docstring updated to reflect the new rule.
- vault-manifest.json: chainCount 879 → 824, releaseHash rolls to
479811040b7a… (real content delta, not a timestamp churn).
Validation:
- vault check --strict: 10,705 loaded, 0 failures
- vault build --local-json: chainCount=824, releaseHash=479811040b…
- pytest: 74/74
- playwright chain-and-vault-smoke: 19/19 (fixtures cloud-0001 +
cloud-0231 are still in their chains post-drop)
Audit findings #2 (gap detection ~50% noise) and #3 (4 pilot drafts
disposition) remain open — see CHAIN_ROADMAP.md Progress Log.
The "Primary chains only / All" filter dropdown that was punted from
Phase 2.3 (ed2ddb51d) so the user could review the bigger UI surface.
Implementation:
- new selectedTier state, default "primary"
- filteredQuestions filter: when "primary", drop questions whose
chain memberships are *all* secondary (questions not in any chain
pass through unchanged — they're tier-irrelevant).
- Tier FilterSelect dropdown next to the existing Level filter.
Default behaviour intentionally hides secondary-only questions —
matches the rest of the Phase 2 surfaces (practice prefers primary,
ChainBadge shows "alt path" pill on secondary, explore picks primary
chains for the related panel). Users opt into seeing the lenient-pass
questions by switching to "All chains".
Tests: new playwright case test8_explore_tier_filter:
- tier filter dropdown rendered
- switching to "All chains" keeps page interactive (no crash on
re-filter)
Smoke suite: 19/19 pass.
Direct postcss already ^8.5.12, but next@16.2.4 was bringing in a
nested postcss@8.4.31 that tripped GHSA-...-postcss CSS-stringify XSS.
Top-level override forces all postcss instances to ^8.5.12 (resolves
8.5.13); nested next/postcss copy is no longer present in lockfile.
Closes Dependabot #45.
Pull in the dev work that landed since yaml-audit was last synced:
- --legacy-json renamed to --local-json (2b381bb949) — script/doc
updates needed below in this branch
- CI workflow refactor (validate-dev / validate-vault now reusable)
- all-contributors automation, gitignore tightening, codespell list
- PR #1622 navbar URL rewrite for dev preview
- PR #1619 clone-size refactor, #1618 milestone3 xor fix, #1617
perceptron seed, #1616 tito status M3
- Chapter 9 PDF layout refinement
- assorted staffml/practice fixes (pickRandom deps, GitHub star gate)
This merges the canonical dev state into yaml-audit so subsequent
work continues on top of the freshest base. Conflicts in
practice/page.tsx + corpus.ts + ARCHITECTURE.md resolved to keep both
sides' additive changes (Phase 2 tier work + dev's later refactors).
Carries the primary/secondary chain tier (from Phase 1) through the
build pipeline into the practice + explore surfaces, so primary chains
are the unmarked default and secondary chains are an opt-in alternative
path the user can deep-link into via ?chain=<id>.
Backend (2.1):
- legacy_export.py emits chain_tiers per question alongside chain_ids
and chain_positions; missing chain-tier defaults to "primary".
- vault build re-run: 2953 chained questions, all carry chain_tiers
(releaseHash unchanged — new field is additive, doesn't perturb the
manifest hash inputs).
- Existing legacy_export tests were stale (asserted on the v1.0 YAML
chains: field path; v1.1 made chains.json the sidecar source).
Rewrote them to write chains.json fixtures into tmp_path and added
chain_tiers assertions, plus a focused
test_chain_tiers_emitted_per_membership case.
TypeScript (2.2):
- Question.chain_tiers? (Record<string, "primary"|"secondary">)
- ChainTier export, ChainInfo.tier required.
- getChainForQuestion / getAllChainsForQuestion populate tier;
getAllChains... sorts primary first.
- New getPrimaryChainForQuestion(qid) helper for default surfaces.
UI (2.3):
- practice page reads ?chain=<id> URL param; defaults to
getPrimaryChainForQuestion when unset.
- ChainBadge gains an inline "alt path" pill when tier=secondary
(always visible — no click needed).
- ChainStrip mirrors that pill in the progress row for users who
expand the strip.
- Explore page prefers the first non-secondary chain when picking
activeChainId for the related-questions panel.
- Deferred to a follow-up commit (intentional, scoped via Progress Log):
explore-page "Primary only / All" filter; daily/mock routing.
Tests (2.4):
- test7_tier_aware_chain_routing in chain-and-vault-smoke.mjs:
secondary reachable via ?chain=, alt-path badge visible on
secondary, primary regression, alt-path badge ABSENT on primary.
- Full smoke suite: 17/17 pass (was 13/13).
Validation:
- vault check --strict: 10,701 loaded, 0 failures
- vault build --legacy-json: 9438 published, chainCount=879
- pytest interviews/vault-cli/tests: 74/74
- npx tsc --noEmit: 0 errors
- playwright chain-and-vault-smoke: 17/17
Phase 2 complete. Next: Phase 3 (gap-driven authoring; 407-gap backlog).
Carries the primary/secondary chain tier (from Phase 1) through the
build pipeline into the practice + explore surfaces, so primary chains
are the unmarked default and secondary chains are an opt-in alternative
path the user can deep-link into via ?chain=<id>.
Backend (2.1):
- legacy_export.py emits chain_tiers per question alongside chain_ids
and chain_positions; missing chain-tier defaults to "primary".
- vault build re-run: 2953 chained questions, all carry chain_tiers
(releaseHash unchanged — new field is additive, doesn't perturb the
manifest hash inputs).
- Existing legacy_export tests were stale (asserted on the v1.0 YAML
chains: field path; v1.1 made chains.json the sidecar source).
Rewrote them to write chains.json fixtures into tmp_path and added
chain_tiers assertions, plus a focused
test_chain_tiers_emitted_per_membership case.
TypeScript (2.2):
- Question.chain_tiers? (Record<string, "primary"|"secondary">)
- ChainTier export, ChainInfo.tier required.
- getChainForQuestion / getAllChainsForQuestion populate tier;
getAllChains... sorts primary first.
- New getPrimaryChainForQuestion(qid) helper for default surfaces.
UI (2.3):
- practice page reads ?chain=<id> URL param; defaults to
getPrimaryChainForQuestion when unset.
- ChainBadge gains an inline "alt path" pill when tier=secondary
(always visible — no click needed).
- ChainStrip mirrors that pill in the progress row for users who
expand the strip.
- Explore page prefers the first non-secondary chain when picking
activeChainId for the related-questions panel.
- Deferred to a follow-up commit (intentional, scoped via Progress Log):
explore-page "Primary only / All" filter; daily/mock routing.
Tests (2.4):
- test7_tier_aware_chain_routing in chain-and-vault-smoke.mjs:
secondary reachable via ?chain=, alt-path badge visible on
secondary, primary regression, alt-path badge ABSENT on primary.
- Full smoke suite: 17/17 pass (was 13/13).
Validation:
- vault check --strict: 10,701 loaded, 0 failures
- vault build --legacy-json: 9438 published, chainCount=879
- pytest interviews/vault-cli/tests: 74/74
- npx tsc --noEmit: 0 errors
- playwright chain-and-vault-smoke: 17/17
Phase 2 complete. Next: Phase 3 (gap-driven authoring; 407-gap backlog).
The keyboard shortcut useEffect captured a stale pickRandom closure
because pickRandom was not in its dependency array. When a user changed
a filter, pool updated and pickRandom got a new identity via useCallback,
but the keyboard handler kept firing the old closure. Pressing N after
a filter change would pick from the pre-filter pool.
Same stale-closure bug existed in pickNext, which calls pickRandom but
only listed [reviewMode, pool] as deps.
Fix: move pickRandom before the keyboard effect (satisfies the
before-use declaration order) and add it to both dep arrays.
The keyboard shortcut useEffect captured a stale pickRandom closure
because pickRandom was not in its dependency array. When a user changed
a filter, pool updated and pickRandom got a new identity via useCallback,
but the keyboard handler kept firing the old closure. Pressing N after
a filter change would pick from the pre-filter pool.
Same stale-closure bug existed in pickNext, which calls pickRandom but
only listed [reviewMode, pool] as deps.
Fix: move pickRandom before the keyboard effect (satisfies the
before-use declaration order) and add it to both dep arrays.
If getQuestionFullDetail returns undefined for even one question, the
previous code's `hydrated.length === selected.length` guard discarded
the entire hydrated batch, silently leaving the whole gauntlet with
empty scenario/details fields regardless of which questions succeeded.
Fall back to the original summary per-question instead so partial
Worker outages degrade gracefully rather than wiping all hydrated data.
If getQuestionFullDetail returns undefined for even one question, the
previous code's `hydrated.length === selected.length` guard discarded
the entire hydrated batch, silently leaving the whole gauntlet with
empty scenario/details fields regardless of which questions succeeded.
Fall back to the original summary per-question instead so partial
Worker outages degrade gracefully rather than wiping all hydrated data.
Brings the vault chain rebuild + sidecar architecture work into dev:
- Hierarchical question layout (interviews/vault/questions/<track>/<area>/<id>.yaml)
completed in earlier dev merge; this branch adds the sidecar split
- chains.json is now the authoritative chain registry; YAML chains: field
stripped from all 10,701 question files
- 373 chains rebuilt via Gemini 3.1 Pro Preview with strict progression
rules (Δ ∈ {1,2}, single-track, single-topic, multi-membership cap=2)
- 138 gaps surfaced into gaps.proposed.json for Phase 3 authoring
- Tooling: build_chains_with_gemini.py, apply_proposed_chains.py,
summarize_proposed_chains.py, diagnose_chain_coverage.py
- CHAIN_ROADMAP.md captures the resumable Phase 1-4 plan
State at merge:
- vault check --strict: 10,701 loaded, 0 invariant failures
- vault build --legacy-json: clean, releaseId=dev, 9438 published, 373 chains
- playwright UI suite (last run on yaml-audit): 13/13 pass
Phase 1.1 (diagnose_chain_coverage.py) shipped on yaml-audit; Phase
1.2-1.6 (lenient sweep, tier merge) still pending. See CHAIN_ROADMAP.md
Progress Log for the resumable cursor.
Brings the vault chain rebuild + sidecar architecture work into dev:
- Hierarchical question layout (interviews/vault/questions/<track>/<area>/<id>.yaml)
completed in earlier dev merge; this branch adds the sidecar split
- chains.json is now the authoritative chain registry; YAML chains: field
stripped from all 10,701 question files
- 373 chains rebuilt via Gemini 3.1 Pro Preview with strict progression
rules (Δ ∈ {1,2}, single-track, single-topic, multi-membership cap=2)
- 138 gaps surfaced into gaps.proposed.json for Phase 3 authoring
- Tooling: build_chains_with_gemini.py, apply_proposed_chains.py,
summarize_proposed_chains.py, diagnose_chain_coverage.py
- CHAIN_ROADMAP.md captures the resumable Phase 1-4 plan
State at merge:
- vault check --strict: 10,701 loaded, 0 invariant failures
- vault build --legacy-json: clean, releaseId=dev, 9438 published, 373 chains
- playwright UI suite (last run on yaml-audit): 13/13 pass
Phase 1.1 (diagnose_chain_coverage.py) shipped on yaml-audit; Phase
1.2-1.6 (lenient sweep, tier merge) still pending. See CHAIN_ROADMAP.md
Progress Log for the resumable cursor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The EcosystemBar dropdowns rendered behind the StaffML Nav bar despite
sitting in a higher-z-index sticky parent. Root cause is the bar's
`overflow-x: clip` + `position: sticky` combo: WebKit composites such
parents into a layer that does not extend past their box, so children
painted below the box (the open dropdown) end up beneath the next
sticky sibling — Nav, whose `backdrop-blur-md` makes it its own
stacking context. Z-index alone cannot fix this.
Render the dropdown via `createPortal` to `document.body` with
`position: fixed` and a coordinate computed from the trigger's
`getBoundingClientRect()`, recomputed on resize and capture-phase
scroll. The menu is now immune to any ancestor's overflow, transform,
filter, or stacking-context shenanigans — present or future. Outside-
click handler accepts clicks inside the portalled dropdown via a
`data-ecosystem-dropdown` attribute; Escape closes; mobile inline
expansion is unchanged.
The EcosystemBar dropdowns rendered behind the StaffML Nav bar despite
sitting in a higher-z-index sticky parent. Root cause is the bar's
`overflow-x: clip` + `position: sticky` combo: WebKit composites such
parents into a layer that does not extend past their box, so children
painted below the box (the open dropdown) end up beneath the next
sticky sibling — Nav, whose `backdrop-blur-md` makes it its own
stacking context. Z-index alone cannot fix this.
Render the dropdown via `createPortal` to `document.body` with
`position: fixed` and a coordinate computed from the trigger's
`getBoundingClientRect()`, recomputed on resize and capture-phase
scroll. The menu is now immune to any ancestor's overflow, transform,
filter, or stacking-context shenanigans — present or future. Outside-
click handler accepts clicks inside the portalled dropdown via a
`data-ecosystem-dropdown` attribute; Escape closes; mobile inline
expansion is unchanged.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaced the 726 author-curated chains with 373 LLM-curated chains
generated bucket-by-bucket within (track, topic). Gemini was prompted
with the strict-progression + multi-chain constraints we agreed on:
- Δ ∈ {1, 2} between consecutive members (prefer +1)
- Up to 2-chain membership only for L1/L2 anchors
- Single-topic, 2-6 members, no Δ=0 same-level pairs
- Validated structurally on apply — vault check --strict passes
Sweep stats:
- 44 calls to gemini-3.1-pro-preview (well under 250/day cap)
- 313 (track, topic) buckets processed in ~80 minutes
- 373 chains accepted (51% of legacy count, much higher per-chain
quality after strict filter)
- Level-Δ distribution: 949 strict +1 (93%), 73 +2 (7%) — 0 +0/+3+
- Chain sizes: 26 size-2, 141 size-3, 128 size-4, 60 size-5, 18 size-6
- 1,395 questions in chains (15% of corpus, vs ~20% before)
- 54 of ~87 topics have at least 1 chain
- 138 corpus gaps identified (gaps.proposed.json) — missing-rung
questions that would complete chains; feeds future authoring pass
Why fewer chains than before is fine:
- Old chains had a long tail with cos<0.65 (worse than random
same-bucket pairs). LLM curation rejects those.
- We trade quantity for pedagogical coherence.
- The 138 gaps capture what was implicit in old chains via
questions-that-shouldnt-have-been-paired; we make it explicit.
Files:
- chains.json — applied (was backed up to chains.json.bak by
apply_proposed_chains.py)
- chains.proposed.json — kept for review/audit
- gaps.proposed.json — authoring backlog
- vault-manifest.json + corpus-summary.json — regenerated
- corpus.json — gitignored (CI regenerates)
Validation: vault check --strict 0 failures, vault build clean,
playwright UI suite 13/13 pass.
The flag is the StaffML frontend's local-dev fallback (read corpus.json
from disk via NEXT_PUBLIC_VAULT_FALLBACK=static), not a deprecated path.
"Legacy" implied "soon to be removed"; "local-json" describes its actual
role and reads correctly in scripts and docs.
- vault-cli: rename CLI flag, parameter, result key, and help text.
- CI workflows + pre-commit config: invoke the new flag name.
- All scripts that print the command (suggest_exemplars,
pre_commit_corpus_guard, promote_validated, rename_legacy_ids,
export_to_staffml, the paper analyze_corpus/generate_*) updated.
- Comments and docs (ARCHITECTURE, CHANGELOG, REVIEWS, TESTING,
MASSIVE_BUILD_RUNBOOK, DEPRECATED, AUTHORING, plus frontend
comments and .env.example / .gitignore) updated.
The "legacy_json" sentinel string in corpus_stats.json._meta.source
is intentionally NOT renamed — it is a stable artifact format read
by downstream paper-generation tooling.
The flag is the StaffML frontend's local-dev fallback (read corpus.json
from disk via NEXT_PUBLIC_VAULT_FALLBACK=static), not a deprecated path.
"Legacy" implied "soon to be removed"; "local-json" describes its actual
role and reads correctly in scripts and docs.
- vault-cli: rename CLI flag, parameter, result key, and help text.
- CI workflows + pre-commit config: invoke the new flag name.
- All scripts that print the command (suggest_exemplars,
pre_commit_corpus_guard, promote_validated, rename_legacy_ids,
export_to_staffml, the paper analyze_corpus/generate_*) updated.
- Comments and docs (ARCHITECTURE, CHANGELOG, REVIEWS, TESTING,
MASSIVE_BUILD_RUNBOOK, DEPRECATED, AUTHORING, plus frontend
comments and .env.example / .gitignore) updated.
The "legacy_json" sentinel string in corpus_stats.json._meta.source
is intentionally NOT renamed — it is a stable artifact format read
by downstream paper-generation tooling.
Track buttons used text-sm + rounded-md + lg:py-2; Competency and Zone
items use text-xs + rounded + py-1.5. Bringing Track in line so the
sidebar filter sections share one visual hierarchy.
Track buttons used text-sm + rounded-md + lg:py-2; Competency and Zone
items use text-xs + rounded + py-1.5. Bringing Track in line so the
sidebar filter sections share one visual hierarchy.
Replace the daily-FREE_LIMIT modal with a single mission-aligned ask
shown once after 5 lifetime reveals. The gate now retires forever on
star, honor-confirm, or dismiss — no daily cap, no username verify.
- Live stargazer count fetched from the GitHub API (24h cache).
- Copy borrows site/about: "Our only ask. Every star tells universities,
publishers, and funders that AI engineering education matters."
- Wires the same gate into the gauntlet revealAnswer path so Mock
Interview no longer bypasses the ask.
- Adds a Playwright smoke covering practice + gauntlet + dismiss
persistence across reloads.
Replace the daily-FREE_LIMIT modal with a single mission-aligned ask
shown once after 5 lifetime reveals. The gate now retires forever on
star, honor-confirm, or dismiss — no daily cap, no username verify.
- Live stargazer count fetched from the GitHub API (24h cache).
- Copy borrows site/about: "Our only ask. Every star tells universities,
publishers, and funders that AI engineering education matters."
- Wires the same gate into the gauntlet revealAnswer path so Mock
Interview no longer bypasses the ask.
- Adds a Playwright smoke covering practice + gauntlet + dismiss
persistence across reloads.
build_chains_with_gemini.py: prompt now asks Gemini to also surface
missing-rung gaps — e.g., 'this bucket has L1 + L3 questions on the same
scenario thread but no L2 to bridge them.' Gaps are captured to
interviews/vault/gaps.proposed.json as a separate authoring backlog.
This is a free signal: it costs no extra calls, identifies pedagogical
holes the corpus doesn't yet fill, and feeds a future generation pass
(with independent validation before any new question is committed).
corpus.ts: getChainForQuestion now accepts an optional preferredChainId
so multi-chain questions can disambiguate via URL (?chain=...). Adds
getAllChainsForQuestion() returning every chain a qid belongs to.
Default behavior unchanged when only one chain exists.
v1.0 -> v1.1: question YAMLs no longer carry a chains: field. The
canonical chain registry is interviews/vault/chains.json. The build
joins YAML + sidecar to produce per-question chain_ids/chain_positions
in the runtime corpus.json.
Why this matters: chain operations (add/remove/reshape) now touch ONE
file (chains.json) instead of rewriting the chains: field across
hundreds of question YAMLs. Lets us regenerate chains in bulk (e.g.,
the upcoming Gemini chain-builder pass) without polluting blame on
1800+ unrelated YAMLs.
Migration applied:
- stripped chains: field from 1929 question YAMLs (regex pass)
- reconciled 1 chains.json/YAML mismatch (cloud-chain-467
membership)
- updated 117 stale level fields in chains.json metadata to match
live YAML levels
- sorted 47 chains by YAML-side Bloom level so position = array index
is monotonic
- loader.load_all() now joins sidecar chain data onto each
LoadedQuestion at parse time (existing q.chains readers still work)
- validator builds chain_members from chains.json registry, not from
q.chains list
- legacy_export reads sidecar to populate corpus.json chain_ids
vault check --strict: 10,701 loaded, 0 invariant failures
vault build: 9,438 published, 726 chains
1,825 questions in corpus.json carry chain_ids (unchanged from before)
Plain string \!== triggered the toast whenever local RELEASE_ID differed
from worker's release_id — including when local was *newer* (dev preview
ahead of prod worker). That produced 'New version available v0.1.0 (you
have v0.1.2-dev)' which reads backwards.
Now uses semver-aware compare: only fire when remote is strictly newer
in the numeric prefix. Suffix differences at the same numeric version
are ignored.
13 checks covering:
- landing page + vault area rendering
- topic drilldown question card preview text (regression for the '...' bug)
- practice page loads + renders chain members
- chain indicator surfaces on chain-member questions
- hierarchical layout doesn't break runtime: practice loads
cloud-0000, edge-0001, mobile-0000, tinyml-0000
All 13 pass against current build. Run via:
cd interviews/staffml && npm run dev
node tests/chain-and-vault-smoke.mjs