Unblocks the four Phase B round-2 exemplar corpora (PR #306) so they can flow through `core teaching propose-from-exemplars`. The corpora were committed in #306 but Phase C's ingest validator + synthesizer were hard-coded to round-1 categories; this PR closes that gap. Extends three modules with the three new categories (discrete_count_statement, multiplicative_aggregation, currency_amount): - teaching/exemplar_ingest.py — per-category validator dispatch + _SUPPORTED_CATEGORIES. The file-stem rule loosens from exact ``<category>_v1`` to ``<category>_v<N>`` so the temporal_aggregation v2 widening from #306 ingests. - teaching/recognizer_synthesis.py — per-category synthesizers following the same observed_*-set + coverage-histogram pattern as round 1. Determinism, narrowness rule (narrower-not-broader), rules-only — same discipline. - generate/recognizer_match.py — per-category matchers shipped as DETECTION-ONLY (return empty parsed_anchors). Consistent with Phase D's current skip-only wiring (PR #302). Real value extraction lands when Phase D.2 plumbs parsed_anchors into the solver; until then, detection-only is the right shape and preserves wrong=0 by construction. graph_intent Literal expanded to include "count" and "amount". Test updates: - tests/test_exemplar_ingest.py: extend _ROUND_1 with _ROUND_2; test_list_corpora_loads_every_round_1_file now asserts every committed corpus (round 1 + round 2) loads. - tests/test_recognizer_registry.py: rename + repair test_live_proposal_log_has_phase_c_pending_proposals → test_live_proposal_log_has_phase_c_proposals. The original asserted state=="pending"; PR #304 ratified the three, so the test now asserts state=="accepted" and registry length matches. Pre-existing failure on main, fixed here. Validation: - 132 passed across exemplar_ingest, recognizer_synthesis, recognizer_match, recognizer_registry, candidate_graph_wiring, admissibility_exemplars, refusal_taxonomy_lane, admissibility_replay_gate - 222 capability-axis tests passed / 2 pre-existing main failures / 3 skipped — G1..G5 + S1 wrong=0 invariant intact - 67 smoke passed - End-to-end CLI sanity check: `core teaching propose-from-exemplars teaching/admissibility_exemplars/discrete_count_statement_v1.jsonl --log /tmp/test.jsonl` produced proposal_id 8c7645b4..., state pending, replay_equivalent=True, wrong_count_delta=0 Empirical projection: of 47 still-refused GSM8K train_sample statements, ~22 match the discrete_count_statement recognizer, ~2 match multiplicative_aggregation, plus 3 rate_with_currency + 3 temporal_aggregation + 18 descriptive_setup_no_quantity recognized under the existing round-1 wiring. After operator ratifies round-2 proposals, the candidate-graph skip-only wiring will drop those sentences from the math state and a meaningful lift is projected. wrong=0 preserved at every level by Phase D's skip-only construction. Scope: enables the round-2 pipeline; does NOT ratify anything; does NOT modify generate/math_candidate_graph.py. Operator runs propose-from-exemplars + review --accept after merge.
456 lines
18 KiB
Python
456 lines
18 KiB
Python
"""ADR-0163 Phase C — admissibility exemplar ingest.
|
|
|
|
Pure-function loader for the operator-authored exemplar corpora under
|
|
``teaching/admissibility_exemplars/``. Returns frozen :class:`ExemplarCorpus`
|
|
records whose canonical bytes (sorted JSONL, single trailing newline) the
|
|
:attr:`ExemplarCorpus.corpus_digest` field hashes deterministically.
|
|
|
|
Trust boundary
|
|
- Pure functions. The only file read is the path supplied by the caller
|
|
(or, in ``list_corpora``, the contents of
|
|
``teaching/admissibility_exemplars/``). No global state, no caches
|
|
outlive a call, no writes.
|
|
- Validation is rules-only. No LLM, no embedding, no learned classifier.
|
|
- The schema enforced here mirrors
|
|
``teaching/admissibility_exemplars/contract.md`` and the per-category
|
|
dispatcher pattern in ``tests/test_admissibility_exemplars.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Mapping
|
|
|
|
from evals.refusal_taxonomy.shape_categories import ShapeCategory
|
|
|
|
|
|
_EXEMPLARS_ROOT_DEFAULT: Path = (
|
|
Path(__file__).resolve().parent / "admissibility_exemplars"
|
|
)
|
|
|
|
_REQUIRED_TOP_KEYS: frozenset[str] = frozenset({
|
|
"exemplar_id", "shape_category", "statement", "expected_graph", "provenance",
|
|
})
|
|
_REQUIRED_GRAPH_KEYS: frozenset[str] = frozenset({
|
|
"subject", "quantity_anchors", "graph_intent", "outcome",
|
|
})
|
|
_REQUIRED_PROVENANCE_KEYS: frozenset[str] = frozenset({
|
|
"source", "author", "round", "category_rank",
|
|
})
|
|
|
|
_VALID_WINDOW_UNITS: frozenset[str] = frozenset({
|
|
"day", "week", "month", "year", "hour", "minute", "second",
|
|
})
|
|
_VALID_WINDOW_QUANTIFIERS: frozenset[str] = frozenset({"each", "every", "per"})
|
|
_VALID_CURRENCY_SYMBOLS: frozenset[str] = frozenset({"$", "£", "€", "¥"})
|
|
_VALID_AMOUNT_KINDS: frozenset[str] = frozenset({"integer", "decimal", "word"})
|
|
# Round-2 categories.
|
|
_VALID_COUNT_KINDS: frozenset[str] = frozenset({"integer", "word"})
|
|
|
|
|
|
# The categories Phase C ingests in round 1. Adding a category here
|
|
# requires landing its exemplar corpus + its synthesizer first.
|
|
_SUPPORTED_CATEGORIES: frozenset[ShapeCategory] = frozenset({
|
|
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY,
|
|
ShapeCategory.TEMPORAL_AGGREGATION,
|
|
ShapeCategory.RATE_WITH_CURRENCY,
|
|
# ADR-0163.B.2 round-2 categories.
|
|
ShapeCategory.DISCRETE_COUNT_STATEMENT,
|
|
ShapeCategory.MULTIPLICATIVE_AGGREGATION,
|
|
ShapeCategory.CURRENCY_AMOUNT,
|
|
})
|
|
|
|
|
|
class ExemplarIngestError(ValueError):
|
|
"""Raised when an exemplar JSONL violates the Phase B contract."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class Exemplar:
|
|
"""One parsed exemplar record.
|
|
|
|
Mirrors the JSONL line verbatim. ``expected_graph`` and
|
|
``provenance`` keep their full submaps so the synthesizer can read
|
|
every field the contract surfaces (including the optional
|
|
``author_note``).
|
|
"""
|
|
|
|
exemplar_id: str
|
|
shape_category: ShapeCategory
|
|
statement: str
|
|
expected_graph: Mapping[str, Any]
|
|
provenance: Mapping[str, Any]
|
|
|
|
@property
|
|
def case_id(self) -> str | None:
|
|
"""Optional GSM8K train-sample case_id this exemplar cites."""
|
|
cid = self.provenance.get("train_case_id")
|
|
return str(cid) if cid else None
|
|
|
|
@property
|
|
def author_note(self) -> str | None:
|
|
note = self.provenance.get("author_note")
|
|
return str(note) if note else None
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ExemplarCorpus:
|
|
"""One ingested exemplar corpus + the digest of its canonical bytes.
|
|
|
|
``corpus_digest`` is a sha256 over the file's canonical re-encoding
|
|
(sorted by ``exemplar_id``, sorted-key JSON, single trailing newline).
|
|
Two corpora whose seeds carry identical content produce identical
|
|
digests regardless of incidental whitespace.
|
|
"""
|
|
|
|
shape_category: ShapeCategory
|
|
path: Path
|
|
exemplars: tuple[Exemplar, ...]
|
|
corpus_digest: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Per-category validation dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _require_keys(
|
|
ctx: str, payload: Mapping[str, Any], required: frozenset[str]
|
|
) -> None:
|
|
missing = required - set(payload.keys())
|
|
if missing:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} missing required keys: {sorted(missing)}"
|
|
)
|
|
|
|
|
|
def _validate_descriptive_setup(ctx: str, graph: Mapping[str, Any]) -> None:
|
|
anchors = graph["quantity_anchors"]
|
|
if not isinstance(anchors, list):
|
|
raise ExemplarIngestError(f"{ctx} quantity_anchors must be list")
|
|
if anchors != []:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} descriptive_setup_no_quantity requires empty anchors"
|
|
)
|
|
if graph["graph_intent"] != "setup":
|
|
raise ExemplarIngestError(f"{ctx} graph_intent must be 'setup'")
|
|
if graph["outcome"] != "inadmissible_by_design":
|
|
raise ExemplarIngestError(
|
|
f"{ctx} outcome must be 'inadmissible_by_design'"
|
|
)
|
|
|
|
|
|
def _validate_temporal_aggregation(ctx: str, graph: Mapping[str, Any]) -> None:
|
|
anchors = graph["quantity_anchors"]
|
|
if not isinstance(anchors, list) or not anchors:
|
|
raise ExemplarIngestError(f"{ctx} temporal_aggregation needs ≥1 anchor")
|
|
for a in anchors:
|
|
if not isinstance(a, Mapping):
|
|
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
|
|
_require_keys(ctx, a, frozenset({
|
|
"kind", "count_token", "window_unit",
|
|
"window_quantifier", "subject_role",
|
|
}))
|
|
if a["kind"] != "event_count_per_window":
|
|
raise ExemplarIngestError(
|
|
f"{ctx} anchor kind must be 'event_count_per_window'"
|
|
)
|
|
if a["window_unit"] not in _VALID_WINDOW_UNITS:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} window_unit {a['window_unit']!r} not in "
|
|
f"{sorted(_VALID_WINDOW_UNITS)}"
|
|
)
|
|
if a["window_quantifier"] not in _VALID_WINDOW_QUANTIFIERS:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} window_quantifier {a['window_quantifier']!r} not in "
|
|
f"{sorted(_VALID_WINDOW_QUANTIFIERS)}"
|
|
)
|
|
if not isinstance(a["count_token"], str) or not a["count_token"]:
|
|
raise ExemplarIngestError(f"{ctx} count_token must be non-empty str")
|
|
if not isinstance(a["subject_role"], str) or not a["subject_role"]:
|
|
raise ExemplarIngestError(f"{ctx} subject_role must be non-empty str")
|
|
if graph["graph_intent"] != "aggregate":
|
|
raise ExemplarIngestError(f"{ctx} graph_intent must be 'aggregate'")
|
|
if graph["outcome"] != "admissible":
|
|
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
|
|
|
|
|
|
def _validate_rate_with_currency(ctx: str, graph: Mapping[str, Any]) -> None:
|
|
anchors = graph["quantity_anchors"]
|
|
if not isinstance(anchors, list) or not anchors:
|
|
raise ExemplarIngestError(f"{ctx} rate_with_currency needs ≥1 anchor")
|
|
for a in anchors:
|
|
if not isinstance(a, Mapping):
|
|
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
|
|
_require_keys(ctx, a, frozenset({
|
|
"kind", "currency_symbol", "amount", "amount_kind",
|
|
"per_unit", "subject_role",
|
|
}))
|
|
if a["kind"] != "currency_per_unit_rate":
|
|
raise ExemplarIngestError(
|
|
f"{ctx} anchor kind must be 'currency_per_unit_rate'"
|
|
)
|
|
if a["currency_symbol"] not in _VALID_CURRENCY_SYMBOLS:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} currency_symbol {a['currency_symbol']!r} not in "
|
|
f"{sorted(_VALID_CURRENCY_SYMBOLS)}"
|
|
)
|
|
if a["amount_kind"] not in _VALID_AMOUNT_KINDS:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} amount_kind {a['amount_kind']!r} not in "
|
|
f"{sorted(_VALID_AMOUNT_KINDS)}"
|
|
)
|
|
for fld in ("amount", "per_unit", "subject_role"):
|
|
if not isinstance(a[fld], str) or not a[fld]:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} {fld} must be non-empty str"
|
|
)
|
|
if graph["graph_intent"] != "rate":
|
|
raise ExemplarIngestError(f"{ctx} graph_intent must be 'rate'")
|
|
if graph["outcome"] != "admissible":
|
|
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
|
|
|
|
|
|
def _validate_discrete_count_statement(ctx: str, graph: Mapping[str, Any]) -> None:
|
|
anchors = graph["quantity_anchors"]
|
|
if not isinstance(anchors, list) or not anchors:
|
|
raise ExemplarIngestError(f"{ctx} discrete_count_statement needs ≥1 anchor")
|
|
for a in anchors:
|
|
if not isinstance(a, Mapping):
|
|
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
|
|
_require_keys(ctx, a, frozenset({
|
|
"kind", "subject_role", "count_token", "count_kind", "counted_noun",
|
|
}))
|
|
if a["kind"] != "discrete_count":
|
|
raise ExemplarIngestError(f"{ctx} anchor kind must be 'discrete_count'")
|
|
if a["count_kind"] not in _VALID_COUNT_KINDS:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} count_kind {a['count_kind']!r} not in "
|
|
f"{sorted(_VALID_COUNT_KINDS)}"
|
|
)
|
|
for fld in ("subject_role", "count_token", "counted_noun"):
|
|
if not isinstance(a[fld], str) or not a[fld]:
|
|
raise ExemplarIngestError(f"{ctx} {fld} must be non-empty str")
|
|
if graph["graph_intent"] != "count":
|
|
raise ExemplarIngestError(f"{ctx} graph_intent must be 'count'")
|
|
if graph["outcome"] != "admissible":
|
|
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
|
|
|
|
|
|
def _validate_multiplicative_aggregation(ctx: str, graph: Mapping[str, Any]) -> None:
|
|
anchors = graph["quantity_anchors"]
|
|
if not isinstance(anchors, list) or not anchors:
|
|
raise ExemplarIngestError(f"{ctx} multiplicative_aggregation needs ≥1 anchor")
|
|
for a in anchors:
|
|
if not isinstance(a, Mapping):
|
|
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
|
|
_require_keys(ctx, a, frozenset({
|
|
"kind", "outer_count", "outer_unit", "inner_count", "inner_unit",
|
|
"subject_role",
|
|
}))
|
|
if a["kind"] != "multiplicative_aggregate":
|
|
raise ExemplarIngestError(
|
|
f"{ctx} anchor kind must be 'multiplicative_aggregate'"
|
|
)
|
|
for fld in (
|
|
"outer_count", "outer_unit", "inner_count", "inner_unit", "subject_role",
|
|
):
|
|
if not isinstance(a[fld], str) or not a[fld]:
|
|
raise ExemplarIngestError(f"{ctx} {fld} must be non-empty str")
|
|
if graph["graph_intent"] != "aggregate":
|
|
raise ExemplarIngestError(f"{ctx} graph_intent must be 'aggregate'")
|
|
if graph["outcome"] != "admissible":
|
|
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
|
|
|
|
|
|
def _validate_currency_amount(ctx: str, graph: Mapping[str, Any]) -> None:
|
|
anchors = graph["quantity_anchors"]
|
|
if not isinstance(anchors, list) or not anchors:
|
|
raise ExemplarIngestError(f"{ctx} currency_amount needs ≥1 anchor")
|
|
for a in anchors:
|
|
if not isinstance(a, Mapping):
|
|
raise ExemplarIngestError(f"{ctx} anchor must be a mapping")
|
|
_require_keys(ctx, a, frozenset({
|
|
"kind", "currency_symbol", "amount", "amount_kind", "subject_role",
|
|
}))
|
|
if a["kind"] != "currency_amount":
|
|
raise ExemplarIngestError(
|
|
f"{ctx} anchor kind must be 'currency_amount'"
|
|
)
|
|
if a["currency_symbol"] not in _VALID_CURRENCY_SYMBOLS:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} currency_symbol {a['currency_symbol']!r} not in "
|
|
f"{sorted(_VALID_CURRENCY_SYMBOLS)}"
|
|
)
|
|
if a["amount_kind"] not in _VALID_AMOUNT_KINDS:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} amount_kind {a['amount_kind']!r} not in "
|
|
f"{sorted(_VALID_AMOUNT_KINDS)}"
|
|
)
|
|
for fld in ("amount", "subject_role"):
|
|
if not isinstance(a[fld], str) or not a[fld]:
|
|
raise ExemplarIngestError(f"{ctx} {fld} must be non-empty str")
|
|
if graph["graph_intent"] != "amount":
|
|
raise ExemplarIngestError(f"{ctx} graph_intent must be 'amount'")
|
|
if graph["outcome"] != "admissible":
|
|
raise ExemplarIngestError(f"{ctx} outcome must be 'admissible'")
|
|
|
|
|
|
_CATEGORY_VALIDATORS = {
|
|
ShapeCategory.DESCRIPTIVE_SETUP_NO_QUANTITY: _validate_descriptive_setup,
|
|
ShapeCategory.TEMPORAL_AGGREGATION: _validate_temporal_aggregation,
|
|
ShapeCategory.RATE_WITH_CURRENCY: _validate_rate_with_currency,
|
|
ShapeCategory.DISCRETE_COUNT_STATEMENT: _validate_discrete_count_statement,
|
|
ShapeCategory.MULTIPLICATIVE_AGGREGATION: _validate_multiplicative_aggregation,
|
|
ShapeCategory.CURRENCY_AMOUNT: _validate_currency_amount,
|
|
}
|
|
|
|
|
|
def _parse_record(path: Path, idx: int, raw: Mapping[str, Any]) -> Exemplar:
|
|
ctx = f"{path}:{idx}"
|
|
_require_keys(ctx, raw, _REQUIRED_TOP_KEYS)
|
|
|
|
cat_str = raw["shape_category"]
|
|
if not any(cat_str == c.value for c in ShapeCategory):
|
|
raise ExemplarIngestError(
|
|
f"{ctx} shape_category {cat_str!r} not in ShapeCategory"
|
|
)
|
|
shape_category = ShapeCategory(cat_str)
|
|
if shape_category not in _SUPPORTED_CATEGORIES:
|
|
raise ExemplarIngestError(
|
|
f"{ctx} shape_category {cat_str!r} is not a Phase C round-1 "
|
|
f"category; supported = "
|
|
f"{sorted(c.value for c in _SUPPORTED_CATEGORIES)}"
|
|
)
|
|
|
|
statement = raw["statement"]
|
|
if not isinstance(statement, str) or not statement:
|
|
raise ExemplarIngestError(f"{ctx} statement must be non-empty str")
|
|
|
|
graph = raw["expected_graph"]
|
|
if not isinstance(graph, Mapping):
|
|
raise ExemplarIngestError(f"{ctx} expected_graph must be a mapping")
|
|
_require_keys(ctx, graph, _REQUIRED_GRAPH_KEYS)
|
|
|
|
prov = raw["provenance"]
|
|
if not isinstance(prov, Mapping):
|
|
raise ExemplarIngestError(f"{ctx} provenance must be a mapping")
|
|
_require_keys(ctx, prov, _REQUIRED_PROVENANCE_KEYS)
|
|
|
|
_CATEGORY_VALIDATORS[shape_category](ctx, graph)
|
|
|
|
return Exemplar(
|
|
exemplar_id=str(raw["exemplar_id"]),
|
|
shape_category=shape_category,
|
|
statement=statement,
|
|
expected_graph=dict(graph),
|
|
provenance=dict(prov),
|
|
)
|
|
|
|
|
|
def _canonical_bytes(records: list[Mapping[str, Any]]) -> bytes:
|
|
"""Re-encode records as sorted-by-exemplar_id canonical JSONL bytes.
|
|
|
|
Two physically different files whose records carry identical content
|
|
produce the same canonical bytes (and hence the same ``corpus_digest``).
|
|
Trailing whitespace, key ordering inside records, and line-by-line
|
|
insertion order are all normalized.
|
|
"""
|
|
sorted_records = sorted(records, key=lambda r: r["exemplar_id"])
|
|
chunks = []
|
|
for r in sorted_records:
|
|
chunks.append(json.dumps(r, sort_keys=True, separators=(",", ":")))
|
|
return ("\n".join(chunks) + "\n").encode("utf-8")
|
|
|
|
|
|
def load_exemplar_corpus(path: Path) -> ExemplarCorpus:
|
|
"""Load and validate one exemplar corpus from *path*.
|
|
|
|
Pure function. Same path + same bytes → identical
|
|
:class:`ExemplarCorpus`. Raises :class:`ExemplarIngestError` for any
|
|
contract violation; partial corpora are never returned.
|
|
"""
|
|
if not path.exists():
|
|
raise ExemplarIngestError(f"exemplar corpus not found: {path}")
|
|
raw = path.read_text(encoding="utf-8")
|
|
if not raw:
|
|
raise ExemplarIngestError(f"exemplar corpus is empty: {path}")
|
|
records_raw: list[Mapping[str, Any]] = []
|
|
parsed: list[Exemplar] = []
|
|
for idx, line in enumerate(raw.splitlines(), start=1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
record = json.loads(line)
|
|
except json.JSONDecodeError as exc:
|
|
raise ExemplarIngestError(
|
|
f"{path}:{idx} invalid JSON: {exc.msg}"
|
|
) from exc
|
|
if not isinstance(record, Mapping):
|
|
raise ExemplarIngestError(
|
|
f"{path}:{idx} record must be a JSON object"
|
|
)
|
|
records_raw.append(record)
|
|
parsed.append(_parse_record(path, idx, record))
|
|
|
|
# File-name to category binding. The contract guarantees one
|
|
# category per file; enforce it on read so a misnamed file fails
|
|
# loudly rather than silently producing a mixed corpus.
|
|
category = parsed[0].shape_category
|
|
for ex in parsed[1:]:
|
|
if ex.shape_category != category:
|
|
raise ExemplarIngestError(
|
|
f"{path} mixes categories: {category.value!r} and "
|
|
f"{ex.shape_category.value!r} both present"
|
|
)
|
|
# File stem must be ``<category>_v<N>`` where N is a positive
|
|
# integer. Round-2 widenings (e.g. ``temporal_aggregation_v2``)
|
|
# are honored under this rule.
|
|
stem_prefix = f"{category.value}_v"
|
|
if not path.stem.startswith(stem_prefix) or not path.stem[len(stem_prefix):].isdigit():
|
|
raise ExemplarIngestError(
|
|
f"{path} stem {path.stem!r} does not match category "
|
|
f"{category.value!r}; expected stem '{stem_prefix}<N>' with "
|
|
f"N a positive integer"
|
|
)
|
|
|
|
# Deterministic order on the in-memory list mirrors the canonical
|
|
# bytes the digest is computed over.
|
|
parsed.sort(key=lambda e: e.exemplar_id)
|
|
|
|
digest = hashlib.sha256(_canonical_bytes(records_raw)).hexdigest()
|
|
|
|
return ExemplarCorpus(
|
|
shape_category=category,
|
|
path=path,
|
|
exemplars=tuple(parsed),
|
|
corpus_digest=digest,
|
|
)
|
|
|
|
|
|
def list_corpora(root: Path | None = None) -> tuple[ExemplarCorpus, ...]:
|
|
"""Load every ``*_v1.jsonl`` under *root* (default exemplars dir).
|
|
|
|
Returns corpora sorted by ``shape_category.value`` so callers get a
|
|
stable iteration order regardless of filesystem listing semantics.
|
|
"""
|
|
base = root if root is not None else _EXEMPLARS_ROOT_DEFAULT
|
|
if not base.is_dir():
|
|
raise ExemplarIngestError(f"exemplars root is not a directory: {base}")
|
|
corpora: list[ExemplarCorpus] = []
|
|
for path in sorted(base.glob("*_v1.jsonl")):
|
|
corpora.append(load_exemplar_corpus(path))
|
|
corpora.sort(key=lambda c: c.shape_category.value)
|
|
return tuple(corpora)
|
|
|
|
|
|
__all__ = [
|
|
"Exemplar",
|
|
"ExemplarCorpus",
|
|
"ExemplarIngestError",
|
|
"list_corpora",
|
|
"load_exemplar_corpus",
|
|
]
|