The line-oriented frontmatter grammar exists in three copies, each with the duplication documented at its site: `materialize` reads a path, `structure` needs a character offset, `profiles` returns body lines. All three keyed on `key.strip()`, which discards the indentation that is the only thing telling a nested key from a top-level one. An indented `title:` under a `sources:` block therefore landed in the same flat namespace as the document's own `title:` and, arriving later, won. The failure is substitution, not omission. A dropped value is visible to whoever reads the concept; a substituted one is not -- the document carries a title that looks entirely right and belongs to something else. Because `number` derives from `title` and `parent` derives from `number`, one substitution walks the hierarchy. Measured, not inferred: a document titled `N100.2` with a nested source titled `N200.7` came back as N200.7 with parent N200 instead of N100.2 with parent N100. Measured incidence across the two corpora, denominators stated: `_okf-canonical` @ ad30107, 54 documents with parsable frontmatter, 49 carry a nested key colliding with a top-level name (90.7%); `_okf-upstream` @ 9a15b13, 66 documents, 58 collide (87.9%). The colliding key is `title`, and often `resource` with it -- in `acme_retail/tables/orders.md` the concept's own BigQuery resource pointer was replaced by a nested one. This is a fix that clears observed damage, not a hardening without a witness. The fix refuses indented lines; it does not read them. Block form stays unreadable -- `sources` and `verified` still come back empty -- so D4's flow-form emission rule is untouched and the structured reader is still D1b. Two characterization tests that pinned the old behaviour now pin the new: the block-list family still DROPS its value, and only the key-space pollution is gone. That family is not otherwise addressed here. Test first, red before the code was touched, with known-positive controls for all three parsers so that a parser returning nothing could not pass. Order: 20260830T000740Z-4733930312-from-.claude Co-Authored-By: Claude <claude-opus-5>
360 lines
15 KiB
Python
360 lines
15 KiB
Python
"""The safety net under OKF v0.2 support (plan steps 1-3).
|
|
|
|
Every test here characterizes behavior this library ALREADY has, before any
|
|
v0.2 code exists. Nothing is changed by them; that is the point. They are
|
|
written first because the one part of the v0.2 work we called risky —
|
|
`_is_ingest_owned`, the pre-mutation collision gate — is only safe to touch
|
|
with its current guarantees pinned.
|
|
|
|
Three groups, one per plan step:
|
|
|
|
1. **Characterization.** An inline flow mapping survives the scalar
|
|
`parse_frontmatter` verbatim (V-A2), a block list does not, and a v0.2
|
|
`generated` mapping can never make a file ingest-owned (V-A3). The last one
|
|
converts today's fail-safe from an accident into a documented guarantee:
|
|
a v0.2 file at a target name is refused, never overwritten.
|
|
2. **Door C consumer tolerance** (D3). OKF §14 forbids a consumer to reject on
|
|
five things; Door C is a consumer in that sense. One case per MUST NOT, plus
|
|
the §5.2 bare-`verified` mapping.
|
|
3. **No profile hard-codes an upstream version** (V4/V-A5). The `okf_version`
|
|
VALUE belongs to catalog (decision E1); a profile names the key and expresses
|
|
any value, which is what keeps "always the latest OKF version" from turning
|
|
into a constant this repo has to chase.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import json
|
|
import re
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from llm_ingestion_okf.errors import MaterializationError
|
|
from llm_ingestion_okf.materialize import (
|
|
_is_ingest_owned,
|
|
materialize_bundle,
|
|
parse_frontmatter,
|
|
)
|
|
from llm_ingestion_okf.profiles import DEFAULT, STRICT_V1, BundleProfile, FrontmatterSchema
|
|
from test_import_flow import StubImportGate, place, run
|
|
|
|
INGESTED_AT = "2026-07-25T12:00:00Z"
|
|
|
|
# The v0.2 `generated` shape, as an inline flow mapping — the form A-E3 pins
|
|
# for the coming profile, and the form the §5 single-line MUST admits.
|
|
V0_2_GENERATED = "{ by: llm-ingestion-okf/0.4.0, at: 2026-07-25T12:00:00Z }"
|
|
|
|
|
|
# --- step 1: what the scalar parser already does --------------------------
|
|
|
|
|
|
def test_an_inline_flow_mapping_survives_the_scalar_parser_verbatim(tmp_path: Path) -> None:
|
|
"""V-A2. `parse_frontmatter` splits on the FIRST colon, so a flow mapping —
|
|
colons and all — comes back as one opaque string. Byte-exact both ways:
|
|
what the emitter wrote is what the parser returns.
|
|
"""
|
|
values = {"type": "dataset", "title": "Orders", "generated": V0_2_GENERATED}
|
|
path = tmp_path / "concept.md"
|
|
path.write_bytes(f"---\n{DEFAULT.frontmatter.emit(values)}\n---\n\nBody.\n".encode())
|
|
|
|
assert path.read_bytes().decode("utf-8").splitlines()[3] == f"generated: {V0_2_GENERATED}"
|
|
assert parse_frontmatter(path) == values
|
|
|
|
|
|
def test_a_block_list_is_dropped_without_polluting_the_key_space(tmp_path: Path) -> None:
|
|
"""The measured reason `sources` is emitted as an inline flow sequence.
|
|
|
|
Upstream's canonical `sources` is a block list of multi-key mappings, and
|
|
this line-oriented parser still cannot READ one: the list value comes back
|
|
empty. That is unchanged, and it remains the whole reason this library
|
|
emits the flow form -- a value it can write is a value it can read back.
|
|
|
|
What changed (2026-08-31, order `...4733930312`) is the second, quieter
|
|
half. The item lines no longer become KEYS. `- id` and `resource` are
|
|
indented, so they belong to the block above them and are refused rather
|
|
than flattened into the document's namespace. The distinction matters
|
|
because `_is_ingest_owned` reads through this same parser: a fabricated
|
|
top-level key is a fact about the document that no document declared.
|
|
|
|
Refusing is not parsing. The block form stays unreadable; it is now
|
|
unreadable LOUDLY rather than by substitution. Reading it is D1b.
|
|
"""
|
|
path = tmp_path / "concept.md"
|
|
path.write_bytes(
|
|
b"---\ntype: dataset\nsources:\n - id: margin-standard\n"
|
|
b" resource: policies/margin-standard.md\n---\n\nBody.\n"
|
|
)
|
|
|
|
parsed = parse_frontmatter(path)
|
|
|
|
assert parsed["sources"] == ""
|
|
assert "- id" not in parsed
|
|
assert "resource" not in parsed
|
|
assert set(parsed) == {"type", "sources"}
|
|
|
|
|
|
def _stamped_concept(generated: str) -> bytes:
|
|
return (
|
|
"---\ntype: dataset\ntitle: Orders\n"
|
|
"ingest_manifest: manifest@0123456789abcdef\n"
|
|
f"generated: {generated}\n---\n\nBody.\n"
|
|
).encode()
|
|
|
|
|
|
def test_a_v0_1_generated_true_is_ingest_owned(tmp_path: Path) -> None:
|
|
"""The control for the test below: the stamp as v0.1 writes it DOES own."""
|
|
path = tmp_path / "ingest-orders.md"
|
|
path.write_bytes(_stamped_concept("true"))
|
|
|
|
assert _is_ingest_owned(path, "manifest") is True
|
|
|
|
|
|
def test_a_v0_2_generated_mapping_is_never_ingest_owned(tmp_path: Path) -> None:
|
|
"""V-A3. `generated` as a v0.2 mapping is not the string `true`, so the
|
|
predicate returns False and the file is not ours to replace.
|
|
|
|
The manifest reference is readable and matches — the refusal comes from
|
|
`generated` alone. This is also why `generated` can never become the
|
|
ownership predicate on its own (U3): upstream writes it for HAND-AUTHORED
|
|
files, so presence proves nothing about who generated what.
|
|
"""
|
|
path = tmp_path / "ingest-orders.md"
|
|
path.write_bytes(_stamped_concept(V0_2_GENERATED))
|
|
|
|
assert parse_frontmatter(path)["ingest_manifest"] == "manifest@0123456789abcdef"
|
|
assert _is_ingest_owned(path, "manifest") is False
|
|
|
|
|
|
def test_a_v0_2_file_at_a_target_name_is_refused_not_overwritten(tmp_path: Path) -> None:
|
|
"""The fail-safe, end to end: not-owned means the §3 collision gate refuses
|
|
the run before any mutation. A v0.2 shape arriving in a bundle Door A also
|
|
writes into can cost a run; it can never cost data.
|
|
"""
|
|
src = tmp_path / "src"
|
|
src.mkdir()
|
|
manifest_path = src / "manifest.json"
|
|
manifest_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"manifest_version": 1,
|
|
"source": {"type": "file", "id": "catalogue-1", "root": "data"},
|
|
"bundle_summary": "A test bundle.",
|
|
"extractions": [
|
|
{
|
|
"id": "orders",
|
|
"title": "Orders",
|
|
"query": "orders.csv",
|
|
"okf_type": "dataset",
|
|
"max_rows": 100,
|
|
}
|
|
],
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
(src / "data").mkdir()
|
|
(src / "data" / "orders.csv").write_text("a,b\n1,x\n", encoding="utf-8", newline="")
|
|
bundle = tmp_path / "bundle"
|
|
|
|
materialize_bundle(manifest_path, bundle, INGESTED_AT)
|
|
concept = bundle / "ingest-orders.md"
|
|
# Substitute the stamp THIS profile wrote for a foreign one. The needle is
|
|
# derived from the profile rather than spelled out because it moved once
|
|
# already (V1 took `DEFAULT` off `generated: true`), and a stale literal
|
|
# here would make `replace` a silent no-op — leaving the file carrying our
|
|
# own stamp and the test passing for a reason it does not describe. The
|
|
# assertion below is the guard against that happening again.
|
|
own_stamp = f"generated: {DEFAULT.ownership.stamp(INGESTED_AT)}".encode()
|
|
original = concept.read_bytes()
|
|
assert own_stamp in original
|
|
v0_2_bytes = original.replace(own_stamp, f"generated: {V0_2_GENERATED}".encode())
|
|
assert v0_2_bytes != original
|
|
concept.write_bytes(v0_2_bytes)
|
|
|
|
with pytest.raises(MaterializationError) as excinfo:
|
|
materialize_bundle(manifest_path, bundle, INGESTED_AT)
|
|
|
|
assert excinfo.value.code == "collision_unstamped"
|
|
assert concept.read_bytes() == v0_2_bytes
|
|
|
|
|
|
# --- step 2: Door C against the §14 consumer tolerance rules --------------
|
|
|
|
TOLERANCE_CASES = [
|
|
pytest.param(
|
|
"---\ntype: Concept\n---\n\nOnly a type.\n",
|
|
id="missing-optional-frontmatter",
|
|
),
|
|
pytest.param(
|
|
"---\ntype: Attested Computation\ntitle: Gross margin\n---\n\nA v0.2 type.\n",
|
|
id="unknown-type-value",
|
|
),
|
|
pytest.param(
|
|
"---\ntype: dataset\nstale_after: 2027-01-01\nnot_a_key_we_know: x\n---\n\nBody.\n",
|
|
id="unknown-additional-keys",
|
|
),
|
|
pytest.param(
|
|
"---\ntype: dataset\n---\n\nSee [the other one](does-not-exist.md).\n",
|
|
id="broken-cross-link",
|
|
),
|
|
pytest.param(
|
|
"---\ntype: dataset\nverified: { by: human:jsmith@acme, at: 2026-07-01T09:00:00Z }\n"
|
|
"---\n\nBody.\n",
|
|
id="bare-verified-mapping",
|
|
),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("document", TOLERANCE_CASES)
|
|
def test_door_c_merges_what_a_consumer_must_not_reject(tmp_path: Path, document: str) -> None:
|
|
"""D3/§14. Each case is one thing a conformant consumer MUST NOT reject.
|
|
|
|
Door C's tolerance is structural rather than lenient: it writes the guard's
|
|
bytes verbatim and never parses the sender's frontmatter, so there is no
|
|
place for a shape judgement to be made. That is what these cases pin — a
|
|
future reader (D1b) that starts judging frontmatter at this door would
|
|
break them, which is exactly when we want to hear about it.
|
|
|
|
The bare-`verified` case is the §5.2 MUST in its Door C form: the mapping is
|
|
persisted unmodified. Coercing it to a one-element list is the READER's
|
|
obligation and lands with D1b; nothing here reads it.
|
|
"""
|
|
place(tmp_path / "source", "notes/tolerated.md", document)
|
|
|
|
result, bundle = run(tmp_path, StubImportGate(), ingested_at=INGESTED_AT)
|
|
|
|
assert result.failed == ()
|
|
assert [entry.concept_path for entry in result.merged] == ["notes/tolerated.md"]
|
|
assert (bundle / "import-notes-tolerated.md").read_bytes() == document.encode("utf-8")
|
|
|
|
|
|
def test_door_c_does_not_require_the_source_bundle_to_carry_an_index(tmp_path: Path) -> None:
|
|
"""The fifth MUST NOT: a missing `index.md` is not grounds for rejection.
|
|
|
|
Door C generates the target index itself, so the sender's bundle need not
|
|
carry one — asserted explicitly here because every other test in the suite
|
|
omits the source index incidentally rather than as a stated rule.
|
|
"""
|
|
document = "---\ntype: dataset\n---\n\nNo index anywhere in the source.\n"
|
|
place(tmp_path / "source", "tables/users.md", document)
|
|
assert not (tmp_path / "source" / "index.md").exists()
|
|
|
|
result, bundle = run(tmp_path, StubImportGate(), ingested_at=INGESTED_AT)
|
|
|
|
assert result.failed == ()
|
|
assert [entry.concept_path for entry in result.merged] == ["tables/users.md"]
|
|
assert (bundle / "index.md").read_text(encoding="utf-8") == (
|
|
"- [tables/users](import-tables-users.md)\n"
|
|
)
|
|
|
|
|
|
# --- step 3: no profile hard-codes an upstream version --------------------
|
|
|
|
# A version LITERAL — `0.1`, `0.2`, `2026.7.3`. Compiled patterns are walked
|
|
# past deliberately: a regex is a shape, and a form gate for version values is
|
|
# the opposite of hard-coding one.
|
|
_VERSION_LITERAL_RE = re.compile(r"^\d+(\.\d+)+$")
|
|
|
|
|
|
def _strings_in(value: Any) -> Iterator[str]:
|
|
"""Every string reachable from a profile, through dataclasses and
|
|
collections alike. Recursive so that a version added to a nested policy —
|
|
the only place it could plausibly be added — is still found."""
|
|
if isinstance(value, str):
|
|
yield value
|
|
elif dataclasses.is_dataclass(value) and not isinstance(value, type):
|
|
for field in dataclasses.fields(value):
|
|
yield from _strings_in(getattr(value, field.name))
|
|
elif isinstance(value, (tuple, list, set, frozenset)):
|
|
for item in value:
|
|
yield from _strings_in(item)
|
|
|
|
|
|
@pytest.mark.parametrize("profile", [DEFAULT, STRICT_V1], ids=["DEFAULT", "STRICT_V1"])
|
|
def test_no_profile_hard_codes_an_upstream_version(profile: BundleProfile) -> None:
|
|
"""V4/V-A5. A profile names `okf_version` as a key and never carries its
|
|
value. The value tracks the upstream Google version and belongs to catalog
|
|
(decision E1), so a constant here would be this repo claiming a decision it
|
|
does not own — and the one that would have to be chased on every upstream
|
|
release.
|
|
"""
|
|
hard_coded = sorted({s for s in _strings_in(profile) if _VERSION_LITERAL_RE.match(s)})
|
|
|
|
assert hard_coded == []
|
|
|
|
|
|
def test_the_key_name_is_what_a_profile_pins(tmp_path: Path) -> None:
|
|
"""The other half of V4: naming the key is allowed and is what STRICT_V1
|
|
does. Without this, the test above would also pass on a profile that had
|
|
stopped requiring the declaration at all."""
|
|
assert "okf_version" in STRICT_V1.index.root_frontmatter
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"schema_kwargs",
|
|
[
|
|
pytest.param({"order": ("timestamp", "generated")}, id="both-emitted"),
|
|
pytest.param(
|
|
{"order": ("timestamp",), "allowed": frozenset({"timestamp", "generated"})},
|
|
id="one-emitted-one-admitted",
|
|
),
|
|
pytest.param(
|
|
{"order": (), "required": frozenset({"timestamp", "generated"})},
|
|
id="both-required",
|
|
),
|
|
pytest.param(
|
|
{"order": ("timestamp", "title"), "nullable": frozenset({"generated"})},
|
|
id="one-emitted-one-nullable",
|
|
),
|
|
],
|
|
)
|
|
def test_a_schema_naming_both_timestamp_and_generated_cannot_be_built(
|
|
schema_kwargs: dict[str, Any],
|
|
) -> None:
|
|
"""V-A7. OKF §13.1 grants the `timestamp` fallback only while `generated` is
|
|
ABSENT, so a schema able to name both can describe a document with neither a
|
|
valid `generated.at` nor an eligible fallback. Refused at construction, the
|
|
same shape as the reserved `verdict` layer: a profile that could reach the
|
|
combination cannot be built, let alone handed to a door.
|
|
|
|
Every judging field names keys, `nullable` included. An open namespace
|
|
already admits `generated`, so a schema that also gives it a null rule has
|
|
named it exactly as surely as one that emits it — the last case is the
|
|
construction the proving consumer found the day the gate was written.
|
|
"""
|
|
with pytest.raises(ValueError, match="timestamp"):
|
|
FrontmatterSchema(**schema_kwargs)
|
|
|
|
|
|
def test_the_two_shipped_profiles_sit_on_opposite_sides_of_the_fallback() -> None:
|
|
"""Why the gate above costs nothing: neither shipped profile is near the
|
|
combination, and they are not near it in opposite directions. `DEFAULT`
|
|
emits `generated` and no `timestamp`; `STRICT_V1` emits `timestamp` and no
|
|
`generated`, which is precisely what puts the wiki on the §13.1 legacy path
|
|
rather than in a defect.
|
|
"""
|
|
assert "generated" in DEFAULT.frontmatter.order
|
|
assert "timestamp" not in DEFAULT.frontmatter.order
|
|
assert "timestamp" in STRICT_V1.frontmatter.order
|
|
assert "generated" not in STRICT_V1.frontmatter.order
|
|
assert STRICT_V1.frontmatter.allowed is not None
|
|
assert "generated" not in STRICT_V1.frontmatter.allowed
|
|
|
|
|
|
@pytest.mark.parametrize("declared", ["0.1", "0.2", "1.0", "2026.7.3"])
|
|
def test_the_root_frontmatter_policy_expresses_any_version_value(declared: str) -> None:
|
|
"""V4. The policy judges presence and ORDER, never the value — so the day
|
|
upstream ships v0.3, no index policy in this library has to change.
|
|
"""
|
|
text = (
|
|
f"---\nokf_version: {declared}\nbundle_profile: strict-v1\n"
|
|
"okf_spec_commit: 0123456789abcdef\n---\n\n# Bundle\n\n* [A](a.md) - a\n"
|
|
)
|
|
|
|
violations = STRICT_V1.index.violations(text, is_root=True, expected_targets={"a.md"})
|
|
|
|
assert violations == ()
|