Skip to content

Commit 7e8444c

Browse files
committed
feat(vault): ID scheme v2 + vault ls/show/chain browse commands
Adds durable ID conventions for new questions and chains, plus three CLI commands that solve the 'know what's there without opening 10k YAMLs' workflow. Legacy IDs are preserved — this is purely additive. Documentation interviews/vault/docs/ID_SCHEMES.md - Question ID: <track>-<yyyymm>-<4hex> e.g. cloud-202604-a3f2. Content-addressed 4-hex from sha256(title + '\n' + topic). Collision-on-create bumps the hex. - Chain ID: chain-<track>-<topic-slug>-<yyyymm>[-suffix] e.g. chain-cloud-kv-cache-management-202604. Self-describing; topic invariant per chain enforced by 'vault lint'. - Durability principle: IDs encode only immutable axes (track + creation month + content hash). Level/zone/topic/status live in the query layer, not the name. - Migration policy: legacy IDs + chain IDs preserved forever; 'vault new' starts using the v2 scheme as of this commit. Authoring (interviews/vault-cli/src/vault_cli/commands/authoring.py) - _new_question_id() generates the v2 question ID with collision handling (increments 4-hex suffix within the same track/yyyymm bucket). - _new_chain_id() generates the v2 chain ID with suffix disambiguation. - 'vault new' now emits v2 IDs instead of the old <track>-<topic-slug>-<short-hash>-<seq> scheme. CLI commands (interviews/vault-cli/src/vault_cli/commands/) vault ls [filters] Browse questions with axis filters (--track, --level, --zone, --topic, --status, --in-chains, --limit, --plain). Columns: id | track | level | zone | topic | #chains | title. --plain emits TSV for grep/awk piping. vault show <question-id> Full classification + validation lineage (LLM, math, human-review) + scenario preview + every chain the question belongs to, with prev/next walk for each. vault chain ls [--track --topic] Lists all chains with member count + level span (e.g. 'L1/L3/L5'). vault chain show <chain-id> Walks the chain end-to-end, one row per member in position order. Warns if the chain spans multiple topics (likely mis-linked) or if the level sequence is not monotonically non-decreasing. Immediately surfaced a real finding: cloud-chain-432 spans both latency-decomposition + roofline-analysis; flagged for review. Tests passed locally for every flag combination above.
1 parent c04f42d commit 7e8444c

6 files changed

Lines changed: 569 additions & 11 deletions

File tree

interviews/vault-cli/src/vault_cli/commands/authoring.py

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,66 @@ def _short_hash(title: str) -> str:
3838
return hashlib.sha256(title.encode("utf-8")).hexdigest()[:6]
3939

4040

41+
def _id_hash(title: str, topic: str) -> str:
42+
"""ID scheme v2 hash: first 4 hex chars of sha256(title + "\\n" + topic).
43+
44+
Matches the recipe documented in interviews/vault/docs/ID_SCHEMES.md.
45+
Including the topic defends against two unrelated questions with
46+
identical titles hashing identically.
47+
"""
48+
payload = f"{title}\n{topic}".encode("utf-8")
49+
return hashlib.sha256(payload).hexdigest()[:4]
50+
51+
52+
def _yyyymm() -> str:
53+
"""Current year-month as 6 digits, for the v2 ID scheme."""
54+
return datetime.now(UTC).strftime("%Y%m")
55+
56+
4157
def _slug(s: str) -> str:
4258
keep = "".join(c if c.isalnum() or c == "-" else "-" for c in s.lower())
4359
while "--" in keep:
4460
keep = keep.replace("--", "-")
4561
return keep.strip("-") or "untitled"
4662

4763

64+
def _new_question_id(track: str, title: str, topic: str, existing_ids: set[str]) -> tuple[str, str]:
65+
"""Mint a v2 question ID: <track>-<yyyymm>-<4hex>.
66+
67+
On collision within the same (track, yyyymm) bucket, increment the
68+
4-hex suffix to the next free hex until a free slot is found.
69+
Returns (id, hex_used).
70+
"""
71+
yyyymm = _yyyymm()
72+
base_hex = _id_hash(title, topic)
73+
# Increment hex on collision (65,536 slots per bucket; collisions rare).
74+
n = int(base_hex, 16)
75+
for _ in range(0x10000):
76+
candidate_hex = f"{n:04x}"
77+
qid = f"{track}-{yyyymm}-{candidate_hex}"
78+
if qid not in existing_ids:
79+
return qid, candidate_hex
80+
n = (n + 1) & 0xFFFF
81+
raise RuntimeError(f"ID-space exhausted for bucket {track}-{yyyymm}")
82+
83+
84+
def _new_chain_id(track: str, topic: str, existing_ids: set[str]) -> str:
85+
"""Mint a v2 chain ID: chain-<track>-<topic-slug>-<yyyymm>[-<suffix>].
86+
87+
On collision add a single-letter suffix (a, b, c, …) to disambiguate.
88+
"""
89+
yyyymm = _yyyymm()
90+
slug = _slug(topic)
91+
base = f"chain-{track}-{slug}-{yyyymm}"
92+
if base not in existing_ids:
93+
return base
94+
for letter in "abcdefghijklmnopqrstuvwxyz":
95+
candidate = f"{base}-{letter}"
96+
if candidate not in existing_ids:
97+
return candidate
98+
raise RuntimeError(f"Chain-ID space exhausted for bucket {base}")
99+
100+
48101
def _git_user_email() -> str | None:
49102
"""Resolve the committer identity for auto-populating ``authors:`` (David H4)."""
50103
try:
@@ -136,8 +189,7 @@ def new_cmd(
136189
rate, per §3.3 concurrency contract.
137190
"""
138191
# v1.0: classification lives in YAML, filesystem uses track only.
139-
topic_slug = _slug(topic)
140-
h = _short_hash(title)
192+
# v2 ID scheme: <track>-<yyyymm>-<4hex> (see docs/ID_SCHEMES.md).
141193

142194
# §3.3: git pull --rebase on the registry before allocation.
143195
if not skip_rebase:
@@ -149,18 +201,14 @@ def new_cmd(
149201
check=False, capture_output=True,
150202
)
151203

152-
# Allocate seq by scanning existing files under the track directory.
204+
# Build the set of existing IDs so _new_question_id can avoid collisions.
153205
cell_dir = path_for_question(vault_dir, track.value, "").parent
154206
cell_dir.mkdir(parents=True, exist_ok=True)
155-
seq = 1
156-
while True:
157-
filename = f"{track.value}-{topic_slug}-{h}-{seq:04d}.yaml"
158-
candidate = cell_dir / filename
159-
if not candidate.exists():
160-
break
161-
seq += 1
207+
existing_ids: set[str] = set()
208+
for p in cell_dir.glob("*.yaml"):
209+
existing_ids.add(p.stem)
162210

163-
qid = f"{track.value}-{topic_slug}-{h}-{seq:04d}"
211+
qid, _ = _new_question_id(track.value, title, topic, existing_ids)
164212
now = _now()
165213

166214
author = _git_user_email()
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""``vault chain`` — browse and inspect question chains.
2+
3+
Subcommands:
4+
vault chain ls [--track --topic] list chains with counts + spans
5+
vault chain show <chain-id> walk a chain end-to-end
6+
7+
A chain links questions on a single topic into a progression, usually
8+
across Bloom's levels. ≈32% of the corpus participates in ≥1 chain;
9+
≈101 questions are in multiple chains.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
from pathlib import Path
15+
16+
import typer
17+
from rich.console import Console
18+
from rich.table import Table
19+
20+
from vault_cli.loader import load_all
21+
22+
23+
chain_app = typer.Typer(help="Browse and inspect question chains.")
24+
25+
26+
def _collect_chains(loaded) -> dict[str, list]:
27+
"""Return {chain_id: [(position, loaded_question), ...]}."""
28+
out: dict[str, list] = {}
29+
for lq in loaded:
30+
for c in (lq.question.chains or []):
31+
out.setdefault(c.id, []).append((c.position, lq))
32+
for cid in out:
33+
out[cid].sort(key=lambda x: x[0])
34+
return out
35+
36+
37+
@chain_app.command("ls")
38+
def chain_ls(
39+
track: str | None = typer.Option(None, "--track", help="Filter by track (cloud/edge/mobile/tinyml/global)."),
40+
topic: str | None = typer.Option(None, "--topic", help="Filter by topic slug."),
41+
vault_dir: Path = typer.Option(Path("interviews/vault"), "--vault-dir"),
42+
) -> None:
43+
"""List chains with member count + level span."""
44+
console = Console()
45+
loaded, _ = load_all(vault_dir)
46+
chains = _collect_chains(loaded)
47+
48+
rows = []
49+
for cid, members in chains.items():
50+
topics = {m[1].question.topic for m in members}
51+
tracks = {m[1].question.track for m in members}
52+
levels = sorted({m[1].question.level for m in members})
53+
first_topic = next(iter(topics))
54+
first_track = next(iter(tracks))
55+
if track and first_track != track:
56+
continue
57+
if topic and topic not in topics:
58+
continue
59+
rows.append((cid, first_track, first_topic, len(members), "/".join(levels)))
60+
61+
rows.sort(key=lambda r: (r[1], r[2], r[0]))
62+
63+
table = Table(show_header=True, header_style="bold")
64+
table.add_column("chain_id", no_wrap=True, style="cyan")
65+
table.add_column("track", no_wrap=True)
66+
table.add_column("topic", no_wrap=True, style="dim")
67+
table.add_column("#", justify="right")
68+
table.add_column("level span")
69+
for cid, tr, tp, n, sp in rows:
70+
table.add_row(cid, tr, tp, str(n), sp)
71+
console.print(table)
72+
console.print(f"[dim]{len(rows)} chains[/dim]")
73+
74+
75+
@chain_app.command("show")
76+
def chain_show(
77+
chain_id: str = typer.Argument(..., help="Chain ID to walk (e.g. cloud-chain-432)."),
78+
vault_dir: Path = typer.Option(Path("interviews/vault"), "--vault-dir"),
79+
) -> None:
80+
"""Walk a chain end-to-end: one row per member, ordered by position."""
81+
console = Console()
82+
loaded, _ = load_all(vault_dir)
83+
chains = _collect_chains(loaded)
84+
85+
members = chains.get(chain_id)
86+
if members is None:
87+
console.print(f"[red]chain not found:[/red] {chain_id!r}")
88+
raise typer.Exit(code=1)
89+
90+
# Detect topic/track drift within the chain.
91+
topics = sorted({m[1].question.topic for m in members})
92+
tracks = sorted({m[1].question.track for m in members})
93+
levels = [m[1].question.level for m in members]
94+
levels_sorted = sorted(levels, key=lambda L: ("L1","L2","L3","L4","L5","L6+").index(L))
95+
monotonic = levels == levels_sorted
96+
97+
console.print(f"[bold cyan]{chain_id}[/bold cyan] "
98+
f"{len(members)} members track(s)={','.join(tracks)} topic(s)={','.join(topics)}")
99+
if len(topics) > 1:
100+
console.print(f" [yellow]warning[/yellow]: chain spans multiple topics — likely mis-linked")
101+
if not monotonic:
102+
console.print(f" [yellow]warning[/yellow]: levels not monotonically non-decreasing across positions")
103+
104+
table = Table(show_header=True, header_style="bold")
105+
table.add_column("#", justify="right", no_wrap=True)
106+
table.add_column("level", no_wrap=True)
107+
table.add_column("zone", no_wrap=True)
108+
table.add_column("id", no_wrap=True, style="cyan")
109+
table.add_column("title")
110+
for pos, lq in members:
111+
q = lq.question
112+
table.add_row(str(pos), q.level, q.zone, q.id, q.title)
113+
console.print(table)
114+
115+
116+
def register(app: typer.Typer) -> None:
117+
app.add_typer(chain_app, name="chain")
118+
119+
120+
__all__ = ["register"]
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""``vault ls`` — browse questions with axis filters.
2+
3+
One-line-per-question output, aligned columns, filterable on every
4+
first-class classification axis. Powers the "I want to see level at a
5+
glance" workflow without opening individual YAMLs.
6+
7+
Usage:
8+
vault ls # every question in the vault
9+
vault ls --track cloud # cloud-only
10+
vault ls --level L6+ # only L6+ questions
11+
vault ls --zone mastery # only mastery-zone
12+
vault ls --topic kv-cache-management
13+
vault ls --status published
14+
vault ls --in-chains # only questions that are in >=1 chain
15+
vault ls --track cloud --level L4 --zone diagnosis # combinable
16+
17+
Output columns: id | track | level | zone | topic | #chains | title
18+
"""
19+
20+
from __future__ import annotations
21+
22+
from pathlib import Path
23+
24+
import typer
25+
from rich.console import Console
26+
from rich.table import Table
27+
28+
from vault_cli.loader import load_all
29+
30+
31+
def register(app: typer.Typer) -> None:
32+
@app.command("ls")
33+
def ls_cmd(
34+
track: str | None = typer.Option(None, "--track", help="Filter by track."),
35+
level: str | None = typer.Option(None, "--level", help="Filter by level (L1 … L6+)."),
36+
zone: str | None = typer.Option(None, "--zone", help="Filter by zone (one of the 11 ikigai zones)."),
37+
topic: str | None = typer.Option(None, "--topic", help="Filter by topic slug."),
38+
status: str | None = typer.Option(None, "--status", help="Filter by status (published, draft, flagged, archived, deleted)."),
39+
in_chains: bool = typer.Option(False, "--in-chains", help="Only questions that belong to ≥1 chain."),
40+
vault_dir: Path = typer.Option(
41+
Path("interviews/vault"), "--vault-dir",
42+
help="Vault root.",
43+
),
44+
limit: int = typer.Option(0, "--limit", "-n", help="Truncate output after N rows (0 = all)."),
45+
plain: bool = typer.Option(False, "--plain", help="Plain tab-separated output (for piping to awk/grep)."),
46+
) -> None:
47+
"""List questions with axis filters. Aligned output or TSV."""
48+
console = Console()
49+
loaded, errors = load_all(vault_dir)
50+
if errors:
51+
console.print(f"[yellow]warning[/yellow]: {len(errors)} load errors (skipped)")
52+
53+
rows = []
54+
for lq in loaded:
55+
q = lq.question
56+
if track and q.track != track: continue
57+
if level and q.level != level: continue
58+
if zone and q.zone != zone: continue
59+
if topic and q.topic != topic: continue
60+
if status and q.status != status: continue
61+
n_chains = len(q.chains or [])
62+
if in_chains and n_chains == 0: continue
63+
rows.append((q.id, q.track, q.level, q.zone, q.topic, n_chains, q.title))
64+
65+
rows.sort(key=lambda r: (r[1], r[2], r[0])) # track, level, id
66+
if limit:
67+
rows = rows[:limit]
68+
69+
if plain:
70+
for r in rows:
71+
print("\t".join(str(x) for x in r))
72+
console.print(f"[dim]({len(rows)} rows)[/dim]", style="dim")
73+
return
74+
75+
table = Table(show_header=True, header_style="bold")
76+
table.add_column("id", no_wrap=True, style="cyan")
77+
table.add_column("track", no_wrap=True)
78+
table.add_column("level", no_wrap=True)
79+
table.add_column("zone", no_wrap=True)
80+
table.add_column("topic", no_wrap=True, style="dim")
81+
table.add_column("ch", justify="right", style="dim")
82+
table.add_column("title", no_wrap=False)
83+
for qid, tr, lv, zn, tp, nc, ti in rows:
84+
table.add_row(qid, tr, lv, zn, tp, str(nc), ti)
85+
console.print(table)
86+
console.print(f"[dim]{len(rows)} rows[/dim]")
87+
88+
89+
__all__ = ["register"]

0 commit comments

Comments
 (0)