fix(profiles,materialize,structure,consume): a block sources sequence is decoded, not skipped

One grammar, four call sites. `read_block_mappings` moves out of
`consume.read_sources` -- where it was written and measured -- into
`profiles`, the module both the flat readers and `consume` already import,
and the three copies of the line-oriented frontmatter grammar now decode a
block sequence for the keys `STRUCTURED_BLOCK_KEYS` names. Two copies of a
block grammar would be two answers to one question.

The value TYPE was the real choice and it was measured, not argued.
`parse_frontmatter` is public API (`okf.parse_frontmatter`) returning
`dict[str, str]`, and a list of mappings is not a `str`. Widening the return
type to `str | list[dict[str, str]]` costs 15 `mypy --strict` errors across
four of the five modules that touch the reader, plus a signature every
caller outside this repository would have to follow. Rendering the entries
back into the flow form those same readers already round-trip costs 0. The
rendering is a READING projection and says so: it is not a claim that the
value is writable -- `yaml_flow_plain` still refuses a `?` and the guard
still refuses a quote inside a flow mapping, which is why the producer
writes block in the first place.

`STRUCTURED_BLOCK_KEYS` is one key wide. `sources` is the key `read_sources`
already knows how to read; a fixture in this tree carries a block
`verified:` that still reads as an empty value, and a test pins that state
so the next widening is a decision rather than a side effect.

Nothing nested reaches the document's namespace: the entries land inside
their own value, and the K3-20 substitution guarantee is asserted per reader
copy.

Three tests that pinned the old behaviour are rewritten to what is now true,
none weakened on its other half: the block round trip in
`test_multi_source_provenance` (the evidence behind `_render_sources`'
reason 1), the v0.2 characterization (whose key-space assertion is the half
that must never weaken), and K3-22's shipped-file known-positive, where the
one difference is counted and pinned at 1.

Suite 1807 passed / 1 skipped, rc 0, 94 s -- 1782/1 before plus 25 new.
ruff clean, `mypy --strict` clean over 21 files, `uv.lock` untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-12 16:42:22 +02:00
commit 28f9a4b540
8 changed files with 211 additions and 65 deletions

View file

@ -65,6 +65,7 @@ from .profiles import (
RESERVED_OKF_TYPE, RESERVED_OKF_TYPE,
SEGMENTED_OKF_V0_2, SEGMENTED_OKF_V0_2,
BundleProfile, BundleProfile,
read_block_mappings,
unquote_scalar, unquote_scalar,
) )
@ -608,28 +609,15 @@ def read_sources(lines: Sequence[str]) -> tuple[tuple[Mapping[str, str], ...], b
if flow is None: if flow is None:
return (), True return (), True
return tuple(flow), True return tuple(flow), True
entries: list[dict[str, str]] = [] # The same grammar the flat readers decode with (K3-24). It lives in
for nested in lines[position + 1 :]: # `profiles` because `materialize` imports that module and this one
if not nested.strip(): # imports `materialize`: one loop, or the two readers would answer the
continue # same question differently. An indented line before any `- ` opens no
if nested[:1] not in (" ", "\t"): # entry and comes back as `None`, refused rather than folded into one.
break entries = read_block_mappings(lines, position)
item = nested.strip() if entries is None:
if item.startswith("- "):
entries.append({})
item = item[2:].strip()
elif not entries:
# An indented line before any `- ` opens no entry. Refused
# rather than folded into one, which would invent an entry the
# document does not have.
return (), True
key, separator, raw = item.partition(":")
if not separator:
return (), True
entries[-1][key.strip()] = unquote_scalar(raw.strip())
if not entries:
return (), True return (), True
return tuple(entries), True return entries, True
return (), False return (), False

View file

@ -29,7 +29,14 @@ from .manifest import (
generated_filename, generated_filename,
load_manifest_bytes, load_manifest_bytes,
) )
from .profiles import DEFAULT, BundleProfile, unquote_scalar, yaml_flow_plain from .profiles import (
DEFAULT,
STRUCTURED_BLOCK_KEYS,
BundleProfile,
block_mapping_value,
unquote_scalar,
yaml_flow_plain,
)
from .render import render_fenced_block, render_table from .render import render_fenced_block, render_table
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -121,7 +128,7 @@ def parse_frontmatter(path: Path) -> dict[str, str]:
if not lines or lines[0].strip() != "---": if not lines or lines[0].strip() != "---":
return {} return {}
frontmatter: dict[str, str] = {} frontmatter: dict[str, str] = {}
for line in lines[1:]: for position, line in enumerate(lines[1:], start=1):
if line.strip() == "---": if line.strip() == "---":
break break
# An INDENTED key belongs to the block above it, not to the document. # An INDENTED key belongs to the block above it, not to the document.
@ -129,16 +136,27 @@ def parse_frontmatter(path: Path) -> dict[str, str]:
# as the top-level keys and, arriving later, SUBSTITUTE for one of them # as the top-level keys and, arriving later, SUBSTITUTE for one of them
# -- a `sources:` entry's own `title:` silently becoming the document's, # -- a `sources:` entry's own `title:` silently becoming the document's,
# carrying `number` and `parent` with it. Skipping is deliberately not # carrying `number` and `parent` with it. Skipping is deliberately not
# parsing: the nested value is not read, only refused. The structured # parsing: the nested value is not read, only refused. That refusal is
# reader is D1b. # unchanged, and `STRUCTURED_BLOCK_KEYS` does not weaken it: a decoded
# block lands INSIDE its own value, never in this namespace. The
# structured reader is still D1b.
if line[:1] in (" ", "\t"): if line[:1] in (" ", "\t"):
continue continue
key, sep, value = line.partition(":") key, sep, value = line.partition(":")
if sep: if sep:
# A `"`-wrapped value is how the emitter writes a scalar a YAML # A `"`-wrapped value is how the emitter writes a scalar a YAML
# reader would refuse plain (K3-22); read back as that reader # reader would refuse plain (K3-22); read back as that reader
# would. A `'`-wrapped one is returned as it stands. # would. A `'`-wrapped one is returned as it stands. A named key
frontmatter[key.strip()] = unquote_scalar(value.strip()) # whose value is a block sequence is DECODED rather than left
# empty (K3-24) -- an empty value is an address disappearing with
# nothing raised.
name, raw = key.strip(), value.strip()
rendered = (
block_mapping_value(lines, position)
if not raw and name in STRUCTURED_BLOCK_KEYS
else None
)
frontmatter[name] = unquote_scalar(raw) if rendered is None else rendered
return frontmatter return frontmatter

View file

@ -195,6 +195,89 @@ def block_scalar(value: str) -> str:
return value if yaml_block_plain(value) else quote_scalar(value) return value if yaml_block_plain(value) else quote_scalar(value)
#: The frontmatter keys whose BLOCK form the line-oriented grammar decodes
#: rather than skips (K3-24). One key wide on purpose: `sources` is the key
#: `consume.read_sources` already knows how to read, so decoding it here adds
#: no second grammar to disagree with the first. Widening this set changes
#: what every flat reader reports for keys no measurement covers -- a fixture
#: in this tree carries a block `verified:` that still reads as empty, and a
#: test pins that state so the next widening is a decision rather than a
#: side effect.
STRUCTURED_BLOCK_KEYS = frozenset({"sources"})
#: A leaf carrying one of these has no plain form inside a flow mapping THIS
#: library's own readers parse back: a comma or a brace would re-split the
#: mapping, a leading `"` would open a quoted scalar. A `?` is absent on
#: purpose -- it is what `yaml_flow_plain` refuses for PyYAML, and refusing it
#: here would refuse exactly the address this decoding exists to carry.
_FLOW_RENDER_UNSAFE = frozenset(",[]{}")
def read_block_mappings(
lines: Sequence[str], position: int
) -> tuple[Mapping[str, str], ...] | None:
"""The block sequence of mappings opened at `lines[position]`, or `None`.
`None` is "this reader cannot decode it", never "there is nothing here":
an indented line before any `- ` opens no entry and is refused rather than
folded into one, which would invent an entry the document does not have.
One grammar, four call sites: the three copies of the line-oriented
frontmatter reader and `consume.read_sources`, which is where this loop
was written and measured. Two copies of a block grammar would be two
answers to one question.
"""
entries: list[dict[str, str]] = []
for nested in lines[position + 1 :]:
if not nested.strip():
continue
if nested[:1] not in (" ", "\t"):
break
item = nested.strip()
if item.startswith("- "):
entries.append({})
item = item[2:].strip()
elif not entries:
return None
key, separator, raw = item.partition(":")
if not separator:
return None
entries[-1][key.strip()] = unquote_scalar(raw.strip())
if not entries:
return None
return tuple(entries)
def render_flow_mappings(entries: Sequence[Mapping[str, str]]) -> str:
"""`entries` as the flow sequence the flat readers already round-trip.
A READING projection, not an emission: the flat grammar's value type is
`str`, and the flow form is the one string shape this library's own
readers decode back into the same entries. It is deliberately NOT a claim
that the rendering is writable -- `yaml_flow_plain` still refuses a
`?` and the guard still refuses a quote inside a flow mapping, so the
emission rule is untouched and a value rendered here may have no writable
flow form at all. That is the whole reason the producer writes block.
"""
items = []
for entry in entries:
pairs = ", ".join(f"{key}: {_flow_leaf(value)}" for key, value in entry.items())
items.append("{ " + pairs + " }" if pairs else "{}")
return "[" + ", ".join(items) + "]"
def _flow_leaf(value: str) -> str:
if not value or value[0] == '"' or any(char in value for char in _FLOW_RENDER_UNSAFE):
return quote_scalar(value)
return value
def block_mapping_value(lines: Sequence[str], position: int) -> str | None:
"""The flow rendering of the block sequence at `position`, or `None`."""
entries = read_block_mappings(lines, position)
return None if entries is None else render_flow_mappings(entries)
@dataclass(frozen=True) @dataclass(frozen=True)
class TypeRejection: class TypeRejection:
"""Why a profile refuses an `okf_type`, for the door to frame and raise. """Why a profile refuses an `okf_type`, for the door to frame and raise.
@ -546,7 +629,18 @@ def _split_frontmatter(text: str) -> tuple[dict[str, str], list[str]]:
continue continue
key, sep, value = line.partition(":") key, sep, value = line.partition(":")
if sep: if sep:
head[key.strip()] = unquote_scalar(value.strip()) name, raw = key.strip(), value.strip()
# A `sources:` block sequence is the one nested shape this grammar
# DECODES instead of skipping: the key is present with an empty
# value otherwise, which is an address disappearing rather than an
# error anyone can catch (K3-24). Nothing nested reaches the
# document's namespace -- the entries land inside the value.
rendered = (
block_mapping_value(lines, offset)
if not raw and name in STRUCTURED_BLOCK_KEYS
else None
)
head[name] = unquote_scalar(raw) if rendered is None else rendered
return head, [] return head, []

View file

@ -35,7 +35,7 @@ from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
from .extract import strip_converter_attribute from .extract import strip_converter_attribute
from .profiles import unquote_scalar from .profiles import STRUCTURED_BLOCK_KEYS, block_mapping_value, unquote_scalar
# A document number is either an alpha-prefixed identifier (`N500`, `V720`, # A document number is either an alpha-prefixed identifier (`N500`, `V720`,
# `R610.4`) or a dotted numeric section (`4.2.1`). A BARE integer is # `R610.4`) or a dotted numeric section (`4.2.1`). A BARE integer is
@ -141,7 +141,7 @@ def _split_frontmatter(text: str) -> tuple[dict[str, str], int]:
return {}, 0 return {}, 0
declared: dict[str, str] = {} declared: dict[str, str] = {}
offset = len(lines[0]) offset = len(lines[0])
for line in lines[1:]: for position, line in enumerate(lines[1:], start=1):
offset += len(line) offset += len(line)
if line.strip() == "---": if line.strip() == "---":
return declared, offset return declared, offset
@ -150,13 +150,24 @@ def _split_frontmatter(text: str) -> tuple[dict[str, str], int]:
# as the top-level keys and, arriving later, SUBSTITUTE for one of them # as the top-level keys and, arriving later, SUBSTITUTE for one of them
# -- a `sources:` entry's own `title:` silently becoming the document's, # -- a `sources:` entry's own `title:` silently becoming the document's,
# carrying `number` and `parent` with it. Skipping is deliberately not # carrying `number` and `parent` with it. Skipping is deliberately not
# parsing: the nested value is not read, only refused. The structured # parsing: the nested value is not read, only refused. That refusal is
# reader is D1b. # unchanged by `STRUCTURED_BLOCK_KEYS`: a decoded block lands INSIDE
# its own value. The structured reader is still D1b.
if line[:1] in (" ", "\t"): if line[:1] in (" ", "\t"):
continue continue
key, sep, value = line.partition(":") key, sep, value = line.partition(":")
if sep: if sep:
declared[key.strip()] = _unquote(value.strip()) name, raw = key.strip(), value.strip()
# A block `sources:` is decoded rather than left empty (K3-24).
# The entries keep `unquote_scalar`'s rule, which is the rule the
# entries were written and read under, rather than this module's
# older `'`-stripping one -- one grammar for the block form.
rendered = (
block_mapping_value(lines, position)
if not raw and name in STRUCTURED_BLOCK_KEYS
else None
)
declared[name] = _unquote(raw) if rendered is None else rendered
# An unterminated block is not frontmatter; the whole text is body. # An unterminated block is not frontmatter; the whole text is body.
return {}, 0 return {}, 0

View file

@ -26,6 +26,7 @@ from llm_ingestion_guard import okf as guard_okf
from llm_ingestion_okf.consume import _frontmatter_lines, _parse_flow_mappings, read_sources from llm_ingestion_okf.consume import _frontmatter_lines, _parse_flow_mappings, read_sources
from llm_ingestion_okf.materialize import parse_frontmatter from llm_ingestion_okf.materialize import parse_frontmatter
from llm_ingestion_okf.profiles import STRUCTURED_BLOCK_KEYS
from llm_ingestion_okf.profiles import _split_frontmatter as _profiles_split from llm_ingestion_okf.profiles import _split_frontmatter as _profiles_split
from llm_ingestion_okf.structure import _split_frontmatter as _structure_split from llm_ingestion_okf.structure import _split_frontmatter as _structure_split
@ -278,7 +279,7 @@ def test_every_fixture_frontmatter_keeps_every_value_a_reference_reader_finds()
cannot turn this green over an empty set.""" cannot turn this green over an empty set."""
paths = _fixture_frontmatters() paths = _fixture_frontmatters()
assert len(paths) == 12 assert len(paths) == 12
carrying_block_sources = 0 carrying_sources_list = 0
for path in paths: for path in paths:
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
flat = parse_frontmatter(path) flat = parse_frontmatter(path)
@ -290,13 +291,15 @@ def test_every_fixture_frontmatter_keeps_every_value_a_reference_reader_finds()
for key, value in reference.items(): for key, value in reference.items():
if value in (None, "", [], {}): if value in (None, "", [], {}):
continue continue
if isinstance(value, (list, dict)) and key != "sources": if isinstance(value, (list, dict)) and key not in STRUCTURED_BLOCK_KEYS:
continue continue
assert flat.get(key, "") != "", f"{path.name}: {key} lost" assert flat.get(key, "") != "", f"{path.name}: {key} lost"
if isinstance(reference.get("sources"), list): if isinstance(reference.get("sources"), list):
carrying_block_sources += 1 carrying_sources_list += 1
assert _parse_flow_mappings(flat["sources"]) == reference["sources"] assert _parse_flow_mappings(flat["sources"]) == reference["sources"]
assert carrying_block_sources == 1 # Two of the twelve: one block form and one flow form, both read by
# PyYAML as a list and both required to decode to the same entries here.
assert carrying_sources_list == 2
def test_a_block_key_outside_the_named_set_is_still_empty() -> None: def test_a_block_key_outside_the_named_set_is_still_empty() -> None:
@ -308,6 +311,7 @@ def test_a_block_key_outside_the_named_set_is_still_empty() -> None:
measurement covers. A fixture in this tree carries a block `verified:` for measurement covers. A fixture in this tree carries a block `verified:` for
exactly this reason, and it still reads as an empty value. Whoever widens exactly this reason, and it still reads as an empty value. Whoever widens
the set will see this test, which is the point of pinning it.""" the set will see this test, which is the point of pinning it."""
assert STRUCTURED_BLOCK_KEYS == frozenset({"sources"})
path = FIXTURES / "consume-bundle" / "dyp" / "nivaa" / "blokkform-verifisert.md" path = FIXTURES / "consume-bundle" / "dyp" / "nivaa" / "blokkform-verifisert.md"
reference = yaml.safe_load(path.read_text(encoding="utf-8").split("---\n")[1]) reference = yaml.safe_load(path.read_text(encoding="utf-8").split("---\n")[1])
assert isinstance(reference["verified"], list) assert isinstance(reference["verified"], list)

View file

@ -30,6 +30,7 @@ from pathlib import Path
import pytest import pytest
from llm_ingestion_okf.consume import _frontmatter_lines, _parse_flow_mappings, read_sources
from llm_ingestion_okf.errors import MaterializationError from llm_ingestion_okf.errors import MaterializationError
from llm_ingestion_okf.manifest import FileSource, HttpSource, Source, SqlSource from llm_ingestion_okf.manifest import FileSource, HttpSource, Source, SqlSource
from llm_ingestion_okf.materialize import _render_sources, parse_frontmatter from llm_ingestion_okf.materialize import _render_sources, parse_frontmatter
@ -100,11 +101,19 @@ def test_two_sources_round_trip_through_our_own_parser(tmp_path: Path) -> None:
assert sorted(frontmatter) == ["generated", "sources", "title", "type"] assert sorted(frontmatter) == ["generated", "sources", "title", "type"]
def test_the_block_form_round_trips_to_nothing(tmp_path: Path) -> None: def test_the_block_form_round_trips_through_the_flat_reader(tmp_path: Path) -> None:
"""The NEGATIVE CONTROL, and the measured reason the block form is not """Two entries go in and BOTH come back (K3-24).
emitted. Two entries go in; an empty string comes back, and no error is
raised anywhere. This test must stay green: it is the evidence, not a This test carried the opposite assertion until 2026-09-12, and it was the
regression guard.""" evidence behind the first of the three reasons `_render_sources` gives for
not emitting the block form: a block list round-tripped to an EMPTY value
with every entry gone, silently. That reason is now false, and the test
says what is true instead of standing as a justification nothing measures.
It is still a control and not a regression guard: what it pins is that
the flat reader and `read_sources` return the same entries from the same
bytes. The emission rule did not move with it -- reasons 2 and 3 are
separate measurements and live in the docstring they belong to."""
path = tmp_path / "concept.md" path = tmp_path / "concept.md"
path.write_text( path.write_text(
"---\n" "---\n"
@ -120,9 +129,16 @@ def test_the_block_form_round_trips_to_nothing(tmp_path: Path) -> None:
) )
frontmatter = parse_frontmatter(path) frontmatter = parse_frontmatter(path)
entries, present = read_sources(_frontmatter_lines(path))
assert frontmatter["sources"] == "" assert present
assert "golden-db" not in "".join(frontmatter.values()) assert _parse_flow_mappings(frontmatter["sources"]) == [
{"id": "golden-catalogue", "resource": "fixture"},
{"id": "golden-db", "resource": "OKF_GOLDEN_SQL_DB"},
]
assert _parse_flow_mappings(frontmatter["sources"]) == [dict(entry) for entry in entries]
# The quieter half, unchanged: an entry's keys stay inside the value.
assert set(frontmatter) == {"type", "sources"}
# --- the refusal applies to every entry, not only the first ----------------- # --- the refusal applies to every entry, not only the first -----------------

View file

@ -66,22 +66,22 @@ def test_an_inline_flow_mapping_survives_the_scalar_parser_verbatim(tmp_path: Pa
def test_a_block_list_is_dropped_without_polluting_the_key_space(tmp_path: Path) -> None: 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 now READS one -- without letting a single
entry key into the document's namespace.
Upstream's canonical `sources` is a block list of multi-key mappings, and Two halves, taken in two different rounds. The quieter one (2026-08-31,
this line-oriented parser still cannot READ one: the list value comes back order `...4733930312`) is unchanged and is what this test still guards:
empty. That is unchanged, and it remains the whole reason this library `- id` and `resource` are indented, belong to the block above them, and
emits the flow form -- a value it can write is a value it can read back. must never be flattened into the document's keys, because
`_is_ingest_owned` reads through this same parser and a fabricated
top-level key is a fact no document declared.
What changed (2026-08-31, order `...4733930312`) is the second, quieter The louder one moved 2026-09-12 (K3-24). The value no longer comes back
half. The item lines no longer become KEYS. `- id` and `resource` are empty: a `sources:` block sequence is decoded into the flow form this
indented, so they belong to the block above them and are refused rather library's readers round-trip, measured against PyYAML and the pinned
than flattened into the document's namespace. The distinction matters guard. `set(parsed)` below is the assertion that carries the first half,
because `_is_ingest_owned` reads through this same parser: a fabricated and it is the one that must never weaken.
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 = tmp_path / "concept.md"
path.write_bytes( path.write_bytes(
@ -91,7 +91,7 @@ def test_a_block_list_is_dropped_without_polluting_the_key_space(tmp_path: Path)
parsed = parse_frontmatter(path) parsed = parse_frontmatter(path)
assert parsed["sources"] == "" assert parsed["sources"] == "[{ id: margin-standard, resource: policies/margin-standard.md }]"
assert "- id" not in parsed assert "- id" not in parsed
assert "resource" not in parsed assert "resource" not in parsed
assert set(parsed) == {"type", "sources"} assert set(parsed) == {"type", "sources"}

View file

@ -43,7 +43,7 @@ from llm_ingestion_okf.errors import MaterializationError
from llm_ingestion_okf.inbox import render_inbox_concept from llm_ingestion_okf.inbox import render_inbox_concept
from llm_ingestion_okf.manifest import FileSource from llm_ingestion_okf.manifest import FileSource
from llm_ingestion_okf.materialize import _render_sources, parse_frontmatter from llm_ingestion_okf.materialize import _render_sources, parse_frontmatter
from llm_ingestion_okf.profiles import DEFAULT, SEGMENTED_OKF_V0_2 from llm_ingestion_okf.profiles import DEFAULT, SEGMENTED_OKF_V0_2, STRUCTURED_BLOCK_KEYS
from llm_ingestion_okf.profiles import _split_frontmatter as _profiles_split from llm_ingestion_okf.profiles import _split_frontmatter as _profiles_split
from llm_ingestion_okf.structure import _split_frontmatter as _structure_split from llm_ingestion_okf.structure import _split_frontmatter as _structure_split
@ -275,7 +275,14 @@ def _strip_only(path: Path) -> dict[str, str]:
def test_every_shipped_frontmatter_reads_exactly_as_before() -> None: def test_every_shipped_frontmatter_reads_exactly_as_before() -> None:
"""Known-positive: the unquoting must be a no-op on every file this """Known-positive: the unquoting must be a no-op on every file this
repository ships, because none of them carries a `"`-wrapped value.""" repository ships, because none of them carries a `"`-wrapped value.
K3-24 added ONE difference on purpose and it is counted rather than waved
through: a `sources:` block sequence is now decoded where the pre-K3-22
reader left the key empty. Every other key on every shipped file must
still read identically, and the count is pinned so a wider change cannot
hide inside this exemption.
"""
paths = [ paths = [
path path
for root in (PROJECT_ROOT / "tests" / "fixtures", PROJECT_ROOT / "examples") for root in (PROJECT_ROOT / "tests" / "fixtures", PROJECT_ROOT / "examples")
@ -283,8 +290,16 @@ def test_every_shipped_frontmatter_reads_exactly_as_before() -> None:
if path.read_text(encoding="utf-8").startswith("---\n") if path.read_text(encoding="utf-8").startswith("---\n")
] ]
assert len(paths) >= 26 assert len(paths) >= 26
decoded = 0
for path in paths: for path in paths:
assert parse_frontmatter(path) == _strip_only(path), path before, now = _strip_only(path), parse_frontmatter(path)
assert set(before) == set(now), path
for key in before:
if key in STRUCTURED_BLOCK_KEYS and before[key] == "" and now[key] != "":
decoded += 1
continue
assert before[key] == now[key], (path, key)
assert decoded == 1
# --- the rules, validated against the reader -------------------------------- # --- the rules, validated against the reader --------------------------------