feat(inbox): surface adjudication state and its dwell time
This commit is contained in:
parent
233cdc5671
commit
a60312a5f3
9 changed files with 219 additions and 9 deletions
|
|
@ -3,5 +3,5 @@ okf_version: 0.2
|
||||||
bundle_id: b-golden-segmented-okf-v0-2
|
bundle_id: b-golden-segmented-okf-v0-2
|
||||||
---
|
---
|
||||||
|
|
||||||
- [Veiledning for eksempelbundel](veiledning.md)
|
- [Veiledning for eksempelbundel](veiledning.md) — adjudication: proposed
|
||||||
- [krav (index)](krav/index.md)
|
- [krav (index)](krav/index.md)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ source_file: veiledning.md
|
||||||
source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d
|
source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d
|
||||||
ingested_at: 2026-08-30T09:00:00Z
|
ingested_at: 2026-08-30T09:00:00Z
|
||||||
generated: true
|
generated: true
|
||||||
|
adjudication: proposed
|
||||||
bundle_id: b-golden-segmented-okf-v0-2
|
bundle_id: b-golden-segmented-okf-v0-2
|
||||||
parent: s0
|
parent: s0
|
||||||
segment_id: s1
|
segment_id: s1
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
- [Foerste krav](foerste-krav.md) — parent: s0?
|
- [Foerste krav](foerste-krav.md) — parent: s0?; adjudication: proposed
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ source_file: veiledning.md
|
||||||
source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d
|
source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d
|
||||||
ingested_at: 2026-08-30T09:00:00Z
|
ingested_at: 2026-08-30T09:00:00Z
|
||||||
generated: true
|
generated: true
|
||||||
|
adjudication: proposed
|
||||||
bundle_id: b-golden-segmented-okf-v0-2
|
bundle_id: b-golden-segmented-okf-v0-2
|
||||||
parent: s0
|
parent: s0
|
||||||
segment_id: s2
|
segment_id: s2
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
- [Andre krav](andre-krav.md) — parent: s0?
|
- [Andre krav](andre-krav.md) — parent: s0?; adjudication: proposed
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ source_file: veiledning.md
|
||||||
source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d
|
source_sha256: 6906ec0acbcfc246e825bda9863c716eb5611b465020e8204eeb448c32343f7d
|
||||||
ingested_at: 2026-08-30T09:00:00Z
|
ingested_at: 2026-08-30T09:00:00Z
|
||||||
generated: true
|
generated: true
|
||||||
|
adjudication: proposed
|
||||||
bundle_id: b-golden-segmented-okf-v0-2
|
bundle_id: b-golden-segmented-okf-v0-2
|
||||||
segment_id: s0
|
segment_id: s0
|
||||||
source_offset: [0, 94]
|
source_offset: [0, 94]
|
||||||
|
|
|
||||||
|
|
@ -194,6 +194,19 @@ def render_inbox_concept(
|
||||||
frontmatter[policy.offset_key] = _render_flow_list([str(offset) for offset in segment.span])
|
frontmatter[policy.offset_key] = _render_flow_list([str(offset) for offset in segment.span])
|
||||||
if segment.parent_id is not None:
|
if segment.parent_id is not None:
|
||||||
frontmatter["parent"] = segment.parent_id
|
frontmatter["parent"] = segment.parent_id
|
||||||
|
if policy.adjudication_key is not None:
|
||||||
|
# The per-entry verdict IS the discriminator. A plan-level
|
||||||
|
# `adjudicated: true` with no record for THIS entry leaves it
|
||||||
|
# `proposed`: B2 forbids the ratified state without the three keys
|
||||||
|
# beside it, so a flag alone cannot promote a segment.
|
||||||
|
verdict = segment.adjudication
|
||||||
|
if verdict is None:
|
||||||
|
frontmatter[policy.adjudication_key] = ADJUDICATION_PROPOSED
|
||||||
|
else:
|
||||||
|
frontmatter[policy.adjudication_key] = ADJUDICATION_ADJUDICATED
|
||||||
|
frontmatter["adjudicated_by"] = verdict.adjudicated_by
|
||||||
|
frontmatter["adjudicated_at"] = verdict.adjudicated_at
|
||||||
|
frontmatter["adjudication_dwell_s"] = str(verdict.adjudication_dwell_s)
|
||||||
return f"---\n{profile.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}"
|
return f"---\n{profile.frontmatter.emit(frontmatter)}\n---\n\n{_normalize_body(text)}"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -847,6 +860,23 @@ def process_inbox(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
#: The adjudication states a segment concept may carry, and the whole set.
|
||||||
|
#: CLOSED on purpose (PM decision B2, `docs/plan/office-intake.md` § 5): a
|
||||||
|
#: value outside it is an error, not an extension point. The consumer's half of
|
||||||
|
#: the same contract is that ABSENCE of the key means `unknown` -- an older
|
||||||
|
#: bundle -- never a collapse to `absent`, so a producer emitting the key
|
||||||
|
#: inconsistently would make that distinction unmeasurable on their side.
|
||||||
|
ADJUDICATION_PROPOSED = "proposed"
|
||||||
|
ADJUDICATION_ADJUDICATED = "adjudicated"
|
||||||
|
ADJUDICATION_STATES = (ADJUDICATION_PROPOSED, ADJUDICATION_ADJUDICATED)
|
||||||
|
|
||||||
|
#: Written beside the state when, and only when, it is `adjudicated`. The dwell
|
||||||
|
#: time travels WITH the verdict: a ratified flag carrying no per-item time is
|
||||||
|
#: unfalsifiable, and it is the same number that makes adjudication throughput
|
||||||
|
#: measurable at all.
|
||||||
|
ADJUDICATION_COMPANION_KEYS = ("adjudicated_by", "adjudicated_at", "adjudication_dwell_s")
|
||||||
|
|
||||||
|
|
||||||
def _validate_facets(structure: DocumentStructure, profile: BundleProfile) -> None:
|
def _validate_facets(structure: DocumentStructure, profile: BundleProfile) -> None:
|
||||||
"""Refuse a document whose values cannot be rendered as index facets.
|
"""Refuse a document whose values cannot be rendered as index facets.
|
||||||
|
|
||||||
|
|
@ -859,8 +889,22 @@ def _validate_facets(structure: DocumentStructure, profile: BundleProfile) -> No
|
||||||
markers are this library's own tokens.
|
markers are this library's own tokens.
|
||||||
"""
|
"""
|
||||||
assert profile.index.facets is not None
|
assert profile.index.facets is not None
|
||||||
|
values = structure_frontmatter(structure, profile.index.facets.keys)
|
||||||
|
policy = profile.segmentation
|
||||||
|
key = None if policy is None else policy.adjudication_key
|
||||||
|
# Refused HERE rather than at reprojection, for the reason above: the only
|
||||||
|
# adjudication value that can be wrong is one the producer declared in the
|
||||||
|
# dropped document itself, and refusing it per file keeps a single bad
|
||||||
|
# document from failing the index for every other one.
|
||||||
|
if key is not None and key in values and values[key] not in ADJUDICATION_STATES:
|
||||||
|
raise MaterializationError(
|
||||||
|
f"frontmatter key {key!r} carries {values[key]!r}, which is outside the closed "
|
||||||
|
f"set {ADJUDICATION_STATES} — the adjudication state is a contract, not an "
|
||||||
|
"extension point",
|
||||||
|
code="index_facet_invalid",
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
profile.index.facets.render(structure_frontmatter(structure, profile.index.facets.keys))
|
profile.index.facets.render(values)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise MaterializationError(str(exc), code="index_facet_invalid") from exc
|
raise MaterializationError(str(exc), code="index_facet_invalid") from exc
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1233,6 +1233,12 @@ SEGMENTED_V1 = BundleProfile(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Bound so the sixth profile's facet extension is typed: `IndexPolicy.facets`
|
||||||
|
# is `FacetPolicy | None`, and `SEGMENTED_V1` is known here to carry one.
|
||||||
|
_SEGMENTED_FACETS = SEGMENTED_V1.index.facets
|
||||||
|
assert _SEGMENTED_FACETS is not None
|
||||||
|
|
||||||
|
|
||||||
# The sixth profile. A segmented bundle could not declare which upstream spec
|
# The sixth profile. A segmented bundle could not declare which upstream spec
|
||||||
# it targets: `SEGMENTED_V1` names `bundle_id`, `OKF_V0_2` names `okf_version`,
|
# it targets: `SEGMENTED_V1` names `bundle_id`, `OKF_V0_2` names `okf_version`,
|
||||||
# and the two never intersected. Additive, as upstream support always is here --
|
# and the two never intersected. Additive, as upstream support always is here --
|
||||||
|
|
@ -1255,7 +1261,21 @@ SEGMENTED_OKF_V0_2 = BundleProfile(
|
||||||
types=OKF_V0_2.types,
|
types=OKF_V0_2.types,
|
||||||
frontmatter=OKF_V0_2.frontmatter,
|
frontmatter=OKF_V0_2.frontmatter,
|
||||||
paths=SEGMENTED_V1.paths,
|
paths=SEGMENTED_V1.paths,
|
||||||
index=replace(SEGMENTED_V1.index, root_frontmatter=("okf_version", "bundle_id")),
|
# The facet tuple is EXTENDED here rather than shared, and that is the
|
||||||
|
# discriminator doing its job: `SEGMENTED_V1.index.facets` is one object
|
||||||
|
# both profiles would otherwise point at, so appending `adjudication` to it
|
||||||
|
# would surface the state under the older profile too and move a
|
||||||
|
# byte-pinned golden. `FacetPolicy.render` refuses any key a policy does
|
||||||
|
# not name, which is why the key has to live here and cannot be added by
|
||||||
|
# the door at write time.
|
||||||
|
index=replace(
|
||||||
|
SEGMENTED_V1.index,
|
||||||
|
root_frontmatter=("okf_version", "bundle_id"),
|
||||||
|
facets=replace(
|
||||||
|
_SEGMENTED_FACETS,
|
||||||
|
keys=_SEGMENTED_FACETS.keys + ("adjudication",),
|
||||||
|
),
|
||||||
|
),
|
||||||
ownership=OKF_V0_2.ownership,
|
ownership=OKF_V0_2.ownership,
|
||||||
# Constructed rather than `replace`d off `SEGMENTED_V1.segmentation`: that
|
# Constructed rather than `replace`d off `SEGMENTED_V1.segmentation`: that
|
||||||
# attribute is typed `| None`, and the equality is asserted in the suite so
|
# attribute is typed `| None`, and the equality is asserted in the suite so
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,12 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from llm_ingestion_okf.inbox import GateDecision, process_inbox
|
from llm_ingestion_okf.inbox import GateDecision, process_inbox
|
||||||
from llm_ingestion_okf.profiles import DEFAULT, SEGMENTED_V1, STRUCTURED_V1
|
from llm_ingestion_okf.profiles import (
|
||||||
|
DEFAULT,
|
||||||
|
SEGMENTED_OKF_V0_2,
|
||||||
|
SEGMENTED_V1,
|
||||||
|
STRUCTURED_V1,
|
||||||
|
)
|
||||||
from llm_ingestion_okf.extract import extract_text
|
from llm_ingestion_okf.extract import extract_text
|
||||||
from llm_ingestion_okf.segmentation import (
|
from llm_ingestion_okf.segmentation import (
|
||||||
SegmentationPlan,
|
SegmentationPlan,
|
||||||
|
|
@ -63,14 +68,26 @@ def _extracted_text_sha256(source_bytes: bytes, filename: str = "n500.md") -> st
|
||||||
return hashlib.sha256(extract_text(filename, source_bytes).encode("utf-8")).hexdigest()
|
return hashlib.sha256(extract_text(filename, source_bytes).encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
def build_plan(source_bytes: bytes, paths: tuple[str, ...] = PATHS, **overrides: Any):
|
def build_plan(
|
||||||
|
source_bytes: bytes,
|
||||||
|
paths: tuple[str, ...] = PATHS,
|
||||||
|
*,
|
||||||
|
entries_override: dict[str, dict[str, Any]] | None = None,
|
||||||
|
text: str | None = None,
|
||||||
|
**overrides: Any,
|
||||||
|
):
|
||||||
|
verdicts = entries_override or {}
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"version": "1",
|
"version": "1",
|
||||||
"source_sha256": hashlib.sha256(source_bytes).hexdigest(),
|
"source_sha256": hashlib.sha256(source_bytes).hexdigest(),
|
||||||
# The hash of the CANONICAL EXTRACTED text, which is what the spans
|
# The hash of the CANONICAL EXTRACTED text, which is what the spans
|
||||||
# index. Equal to the source hash on a `.md` passthrough and computed
|
# index. Equal to the source hash on a `.md` passthrough and computed
|
||||||
# rather than copied, so the fixture keeps saying which one it means.
|
# rather than copied, so the fixture keeps saying which one it means.
|
||||||
"text_sha256": _extracted_text_sha256(source_bytes),
|
"text_sha256": (
|
||||||
|
hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
if text is not None
|
||||||
|
else _extracted_text_sha256(source_bytes)
|
||||||
|
),
|
||||||
"extractor_id": "md",
|
"extractor_id": "md",
|
||||||
"extractor_version": observed_extractor_version("md"),
|
"extractor_version": observed_extractor_version("md"),
|
||||||
"adjudicated_at": "2026-08-30T08:00:00Z",
|
"adjudicated_at": "2026-08-30T08:00:00Z",
|
||||||
|
|
@ -82,6 +99,7 @@ def build_plan(source_bytes: bytes, paths: tuple[str, ...] = PATHS, **overrides:
|
||||||
"okf_type": "requirement",
|
"okf_type": "requirement",
|
||||||
"span": [index * 10, index * 10 + 10],
|
"span": [index * 10, index * 10 + 10],
|
||||||
"ingested_at": PLAN_AT,
|
"ingested_at": PLAN_AT,
|
||||||
|
**({"adjudication": verdicts[path]} if path in verdicts else {}),
|
||||||
}
|
}
|
||||||
for index, path in enumerate(paths)
|
for index, path in enumerate(paths)
|
||||||
],
|
],
|
||||||
|
|
@ -105,7 +123,13 @@ def run(
|
||||||
okf_type="requirement",
|
okf_type="requirement",
|
||||||
gate=gate,
|
gate=gate,
|
||||||
profile=profile,
|
profile=profile,
|
||||||
root_frontmatter_values={"bundle_id": "b-1"} if profile is SEGMENTED_V1 else None,
|
root_frontmatter_values=(
|
||||||
|
{"bundle_id": "b-1"}
|
||||||
|
if profile is SEGMENTED_V1
|
||||||
|
else {"bundle_id": "b-1", "okf_version": "0.2"}
|
||||||
|
if profile is SEGMENTED_OKF_V0_2
|
||||||
|
else None
|
||||||
|
),
|
||||||
segmentation=plan,
|
segmentation=plan,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -270,3 +294,122 @@ def test_default_and_structured_write_one_root_index_only(tmp_path: Path) -> Non
|
||||||
assert [path.relative_to(bundle).as_posix() for path in bundle.rglob("index.md")] == [
|
assert [path.relative_to(bundle).as_posix() for path in bundle.rglob("index.md")] == [
|
||||||
"index.md"
|
"index.md"
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --- B2: the adjudication state, under the profile that asks for it --------
|
||||||
|
#
|
||||||
|
# The wire form is a CONTRACT with `portfolio-optimiser` (`docs/plan/
|
||||||
|
# office-intake.md` § 5), not a naming choice this repo may revise: key
|
||||||
|
# `adjudication`, CLOSED value set `proposed` | `adjudicated`, and when the
|
||||||
|
# value is `adjudicated` three further keys -- `adjudicated_by`,
|
||||||
|
# `adjudicated_at` (ISO 8601) and `adjudication_dwell_s` (an INTEGER number of
|
||||||
|
# seconds). The consumer's half is that ABSENCE means the state `unknown` (an
|
||||||
|
# older bundle), never a collapse to `absent`, so a producer emitting the key
|
||||||
|
# inconsistently would make that distinction unmeasurable on their side.
|
||||||
|
#
|
||||||
|
# The discriminator is `SegmentationPolicy.adjudication_key`, never
|
||||||
|
# `profile.segmentation is not None`: BOTH segmented profiles satisfy the
|
||||||
|
# latter, so keying on it would write the state into `SEGMENTED_V1` too and
|
||||||
|
# move a byte-pinned golden.
|
||||||
|
|
||||||
|
VERDICT = {
|
||||||
|
"adjudicated_by": "ktg",
|
||||||
|
"adjudicated_at": "2026-09-02T10:00:00Z",
|
||||||
|
"adjudication_dwell_s": 41,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def frontmatter_of(path: Path) -> dict[str, str]:
|
||||||
|
head = path.read_text(encoding="utf-8").split("---\n")[1]
|
||||||
|
return dict(
|
||||||
|
line.split(": ", 1) for line in head.splitlines() if ": " in line and line[:1] != " "
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_v0_2(tmp: Path, *, adjudicated: tuple[str, ...] = (), bundle_name: str = "bundle"):
|
||||||
|
source = drop(tmp / "round", "n500.md")
|
||||||
|
plan = build_plan(
|
||||||
|
source.read_bytes(),
|
||||||
|
entries_override={path: dict(VERDICT) for path in adjudicated},
|
||||||
|
)
|
||||||
|
return run(tmp, plan=plan, profile=SEGMENTED_OKF_V0_2, bundle_name=bundle_name)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unratified_segment_carries_the_proposed_state(tmp_path: Path) -> None:
|
||||||
|
build_v0_2(tmp_path)
|
||||||
|
values = frontmatter_of(tmp_path / "bundle" / "krav" / "forord.md")
|
||||||
|
assert values["adjudication"] == "proposed"
|
||||||
|
assert "adjudicated_by" not in values
|
||||||
|
assert "adjudication_dwell_s" not in values
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_ratified_segment_carries_its_adjudicator_time_and_dwell(tmp_path: Path) -> None:
|
||||||
|
build_v0_2(tmp_path, adjudicated=("krav/forord.md",))
|
||||||
|
values = frontmatter_of(tmp_path / "bundle" / "krav" / "forord.md")
|
||||||
|
assert values["adjudication"] == "adjudicated"
|
||||||
|
assert values["adjudicated_by"] == "ktg"
|
||||||
|
assert values["adjudicated_at"] == "2026-09-02T10:00:00Z"
|
||||||
|
# An INTEGER number of seconds, per B2 -- not a float and not a duration
|
||||||
|
# string. A ratified flag with no per-item time is unfalsifiable, and this
|
||||||
|
# is the same field that instruments adjudication throughput.
|
||||||
|
assert values["adjudication_dwell_s"] == "41"
|
||||||
|
assert int(values["adjudication_dwell_s"]) == 41
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_state_is_projected_as_an_index_facet(tmp_path: Path) -> None:
|
||||||
|
build_v0_2(tmp_path, adjudicated=("krav/forord.md",))
|
||||||
|
entries = indexes(tmp_path / "bundle", profile=SEGMENTED_OKF_V0_2)
|
||||||
|
facets = [entry.facets for listing in entries.values() for entry in listing if entry.facets]
|
||||||
|
states = {facet.get("adjudication") for facet in facets}
|
||||||
|
assert "adjudicated" in states
|
||||||
|
assert "proposed" in states
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_facet_survives_a_reprojection_of_the_whole_bundle(tmp_path: Path) -> None:
|
||||||
|
"""The index is a PROJECTION recomputed from stored frontmatter each round.
|
||||||
|
|
||||||
|
A state that lived only in the index would be lost the moment the index
|
||||||
|
was rebuilt, which is every round.
|
||||||
|
"""
|
||||||
|
build_v0_2(tmp_path, adjudicated=("krav/forord.md",))
|
||||||
|
first = tree(tmp_path / "bundle")
|
||||||
|
build_v0_2(tmp_path, adjudicated=("krav/forord.md",))
|
||||||
|
assert tree(tmp_path / "bundle") == first
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_state_outside_the_closed_set_is_refused(tmp_path: Path) -> None:
|
||||||
|
"""Closed means closed: a value outside it is an error, not an extension.
|
||||||
|
|
||||||
|
Reaches the door the way an outside value actually can -- declared in the
|
||||||
|
dropped document's own frontmatter, before this library writes anything.
|
||||||
|
"""
|
||||||
|
body = "---\nadjudication: nesten-ferdig\n---\n\n" + DOCUMENT
|
||||||
|
source = drop(tmp_path / "round", "n500.md", body)
|
||||||
|
# The span covers the block, which is the only way a declared value reaches
|
||||||
|
# derivation at all: a segment body is a SLICE, so a frontmatter block the
|
||||||
|
# span misses is simply not part of that concept.
|
||||||
|
plan = build_plan(
|
||||||
|
source.read_bytes(),
|
||||||
|
paths=("krav/forord.md",),
|
||||||
|
text=body,
|
||||||
|
entries=[
|
||||||
|
{
|
||||||
|
"segment_id": "s0",
|
||||||
|
"path": "krav/forord.md",
|
||||||
|
"title": "Forord",
|
||||||
|
"okf_type": "requirement",
|
||||||
|
"span": [0, len(body)],
|
||||||
|
"ingested_at": PLAN_AT,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
result = run(tmp_path, plan=plan, profile=SEGMENTED_OKF_V0_2)
|
||||||
|
assert {entry.error.code for entry in result.failed} == {"index_facet_invalid"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_segmented_v1_writes_no_adjudication_state_at_all(tmp_path: Path) -> None:
|
||||||
|
"""The discriminator, measured: the older profile's bytes must not move."""
|
||||||
|
build(tmp_path, bundle_name="bundle")
|
||||||
|
values = frontmatter_of(tmp_path / "bundle" / "krav" / "forord.md")
|
||||||
|
assert "adjudication" not in values
|
||||||
|
assert "adjudicated_by" not in values
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue