feat(okf): adjudication_for names the unknown state and a tool carries it
Co-Authored-By: Claude <claude-opus-5>
This commit is contained in:
parent
d62e89935c
commit
ccd65d3b01
3 changed files with 191 additions and 1 deletions
|
|
@ -455,6 +455,60 @@ def trust_tier(entries: tuple[dict[str, str], ...] | None) -> TrustTier:
|
||||||
return "machine-confirmed"
|
return "machine-confirmed"
|
||||||
|
|
||||||
|
|
||||||
|
class AdjudicationValueError(ValueError):
|
||||||
|
"""A concept declares an ``adjudication`` value outside the closed vocabulary.
|
||||||
|
|
||||||
|
A ``ValueError`` subclass (the ``IngestStampError`` precedent) so a malformed knowledge base
|
||||||
|
reaches the CLI's refusal tuple and hosting's 400 arm rather than the crash channel."""
|
||||||
|
|
||||||
|
|
||||||
|
#: Whether a concept has been ADJUDICATED, per the cross-repo contract. The value set on the wire
|
||||||
|
#: is CLOSED (``proposed`` | ``adjudicated``); the third token is this consumer's, and it is what
|
||||||
|
#: absence means. See ``docs/okf-konsum-kontrakter.md`` § 2, which is the source for this rule.
|
||||||
|
AdjudicationState = Literal["proposed", "adjudicated", "unknown"]
|
||||||
|
|
||||||
|
#: The closed on-the-wire vocabulary. ``unknown`` is deliberately NOT in it: it is what this reader
|
||||||
|
#: concludes from absence, never something a document may declare.
|
||||||
|
_ADJUDICATION_WIRE_VALUES = ("proposed", "adjudicated")
|
||||||
|
|
||||||
|
|
||||||
|
def adjudication_for(path: str | Path) -> AdjudicationState:
|
||||||
|
"""Read a concept's adjudication state, with absence as a FIRST-CLASS state.
|
||||||
|
|
||||||
|
A concept that does not carry the key is ``unknown`` — *we did not learn whether this was
|
||||||
|
adjudicated*, which is what an older bundle looks like. **Collapsing that into ``absent``**
|
||||||
|
(*it was not adjudicated*) **is the same defect as collapsing ``unreadable`` into ``absent``**
|
||||||
|
one layer up, and the same defect removed from ``RunResult.verdict``.
|
||||||
|
|
||||||
|
A value outside the two named ones is **refused by name**, never mapped to ``unknown``:
|
||||||
|
validation, never repair. An EMPTY value is refused for the same reason — present-but-empty is
|
||||||
|
a different fact from absent, and folding it in would recreate the collapse this function
|
||||||
|
exists to prevent.
|
||||||
|
|
||||||
|
**Read through ``parse_frontmatter``, NOT ``evidence_for`` — and that is a MEASUREMENT, not a
|
||||||
|
preference.** The plan specified ``evidence_for(path, key="adjudication")``; the contract as
|
||||||
|
delivered makes ``adjudication`` a plain SCALAR, and ``evidence_for`` routes its value through
|
||||||
|
``decode_flow_value``. Measured 2026-09-02: BOTH valid values come back as an identical
|
||||||
|
``state='unreadable', reason='unsupported-flow'`` record, so that route cannot tell ``proposed``
|
||||||
|
from ``adjudicated`` at all. ``parse_frontmatter`` is the repo's scalar reader over the SAME
|
||||||
|
``_split_frontmatter`` scan, so this is a second READER of one parse — never a second parser,
|
||||||
|
which is the rule that route was reaching for.
|
||||||
|
|
||||||
|
Gated by ``tests/test_falsification_verdict_loadbearing.py``."""
|
||||||
|
raw = parse_frontmatter(path).get("adjudication")
|
||||||
|
if raw is None:
|
||||||
|
return "unknown"
|
||||||
|
value = unquote_scalar(raw)
|
||||||
|
if value not in _ADJUDICATION_WIRE_VALUES:
|
||||||
|
raise AdjudicationValueError(
|
||||||
|
f"{value!r} is not an `adjudication` value — the vocabulary is closed to "
|
||||||
|
f"{' | '.join(_ADJUDICATION_WIRE_VALUES)}, and mapping an unrecognised one to "
|
||||||
|
"`unknown` would invent the very state that token exists to keep honest"
|
||||||
|
)
|
||||||
|
# ``value`` is one of the wire literals, which the Literal type also admits.
|
||||||
|
return value # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
#: What a document says about ONE provenance key. Three states, and the third is the whole point:
|
#: What a document says about ONE provenance key. Three states, and the third is the whole point:
|
||||||
#: collapsing ``unreadable`` into ``absent`` turns a verdict on missing evidence into evidence of
|
#: collapsing ``unreadable`` into ``absent`` turns a verdict on missing evidence into evidence of
|
||||||
#: absence.
|
#: absence.
|
||||||
|
|
|
||||||
57
src/portfolio_optimiser/tools.py
Normal file
57
src/portfolio_optimiser/tools.py
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
"""Library-level MAF tool carriers over the framework-neutral ``okf`` readers.
|
||||||
|
|
||||||
|
One named carrier per reader whose answer a hypothesiser needs to be able to ASK for. The gate on
|
||||||
|
this module is the carrier, not the vocabulary it carries: an accessor that returns three tokens
|
||||||
|
which nothing propagates is a vocabulary, not a seam.
|
||||||
|
|
||||||
|
**Import direction is one-way and load-bearing:** this module imports ``okf``, never the reverse.
|
||||||
|
``okf`` is MAF-free by invariant (``test_okf_is_maf_free``, D7 vendor-neutrality) and this module
|
||||||
|
is not, so a back-edge would drag ``agent_framework`` into the portable context layer.
|
||||||
|
|
||||||
|
**Not the MCP seam.** ``mcp_tools`` is the opt-in EXTERNAL seam with its own allowlist and
|
||||||
|
announcement rules; a local library primitive placed there would sit behind an egress contract it
|
||||||
|
has no business in. These carriers make no network call and announce nothing.
|
||||||
|
|
||||||
|
**Deliberately NOT wired into ``run_project``.** Wiring a carrier into the run surface would move
|
||||||
|
the byte-pinned demo transcript, which nothing asks for, and would make the state reachable only by
|
||||||
|
reading a run's output rather than by calling a function — the ``evidence_for`` rule, unchanged.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from agent_framework import FunctionTool, tool
|
||||||
|
|
||||||
|
from portfolio_optimiser import okf
|
||||||
|
from portfolio_optimiser.retrieval import safe_resolve
|
||||||
|
|
||||||
|
|
||||||
|
def adjudication_payload(bundle_dir: str, concept: str) -> dict[str, Any]:
|
||||||
|
"""The carrier's answer: the concept asked about, AND the state read for it.
|
||||||
|
|
||||||
|
Both halves are the point. A payload naming the concept without its state would leave
|
||||||
|
``adjudication_for`` perfectly correct and the hypothesiser none the wiser, which is exactly
|
||||||
|
the silent gap this seam closes.
|
||||||
|
|
||||||
|
Path-safe via ``safe_resolve`` (fail-closed), so a concept name escaping the bundle raises
|
||||||
|
rather than reading an arbitrary file."""
|
||||||
|
resolved = safe_resolve(bundle_dir, concept)
|
||||||
|
return {"concept": concept, "adjudication": okf.adjudication_for(resolved)}
|
||||||
|
|
||||||
|
|
||||||
|
def make_adjudication_tool(bundle_dir: str) -> FunctionTool:
|
||||||
|
"""The ONE named carrier exposing a concept's adjudication state to a hypothesiser."""
|
||||||
|
|
||||||
|
@tool(
|
||||||
|
name="concept_adjudication_state",
|
||||||
|
description=(
|
||||||
|
"Report whether a concept in the knowledge base has been adjudicated. Returns "
|
||||||
|
"`proposed`, `adjudicated`, or `unknown` when the concept does not say — `unknown` "
|
||||||
|
"means the state was not learned, never that adjudication did not happen."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def concept_adjudication_state(concept: str) -> dict[str, Any]:
|
||||||
|
return adjudication_payload(bundle_dir, concept)
|
||||||
|
|
||||||
|
return concept_adjudication_state
|
||||||
|
|
@ -18,9 +18,12 @@ reusing it would mean editing a frozen module to reach a repo-owned fixture.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from portfolio_optimiser import okf, verdicts
|
import pytest
|
||||||
|
|
||||||
|
from portfolio_optimiser import okf, tools, verdicts
|
||||||
|
|
||||||
_GOLDEN_DIR = Path(__file__).resolve().parents[1] / "tests" / "golden" / "block-form-provenance"
|
_GOLDEN_DIR = Path(__file__).resolve().parents[1] / "tests" / "golden" / "block-form-provenance"
|
||||||
|
|
||||||
|
|
@ -188,3 +191,79 @@ def test_a_discounted_concept_reports_the_TRIPLE_not_merely_the_refusal() -> Non
|
||||||
"block-sequence",
|
"block-sequence",
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Step 8: the adjudication state, named and never collapsed to absent ------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _concept(text: str) -> Path:
|
||||||
|
path = Path(tempfile.mkdtemp(prefix="okf-adjudication-")) / "c.md"
|
||||||
|
path.write_text(text, encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["proposed", "adjudicated"])
|
||||||
|
def test_the_two_named_states_are_read_back(value: str) -> None:
|
||||||
|
"""Hand-written fixtures, to the contract as delivered.
|
||||||
|
|
||||||
|
**Honesty limit, stated:** these are written to the contract, NOT produced by
|
||||||
|
``llm-ingestion-okf``. Integration against the producer's own golden is later work and is not
|
||||||
|
claimed here.
|
||||||
|
"""
|
||||||
|
assert okf.adjudication_for(
|
||||||
|
_concept(f"---\ntype: concept\nadjudication: {value}\n---\nb\n")
|
||||||
|
) == (value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_missing_key_is_unknown_and_NEVER_absent() -> None:
|
||||||
|
"""The third token is the whole step.
|
||||||
|
|
||||||
|
A concept that does not carry the key means *we did not learn whether this was adjudicated* —
|
||||||
|
an older bundle. Collapsing that into "it was not adjudicated" is the same defect as collapsing
|
||||||
|
``unreadable`` into ``absent`` one layer up, and the same defect removed from
|
||||||
|
``RunResult.verdict``.
|
||||||
|
"""
|
||||||
|
assert okf.adjudication_for(_concept("---\ntype: concept\ntitle: x\n---\nb\n")) == "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["ratified", "PROPOSED", ""])
|
||||||
|
def test_a_value_outside_the_vocabulary_is_refused_by_name(value: str) -> None:
|
||||||
|
"""Validation, never repair. Mapping an unknown value to ``unknown`` would invent the very
|
||||||
|
state this contract exists to keep honest — and an EMPTY value is present-but-empty, which is a
|
||||||
|
different fact from absent and must not be folded into it either."""
|
||||||
|
with pytest.raises(okf.AdjudicationValueError) as excinfo:
|
||||||
|
okf.adjudication_for(_concept(f"---\ntype: concept\nadjudication: {value}\n---\nb\n"))
|
||||||
|
assert "adjudication" in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_carrier_hands_the_STATE_to_a_hypothesiser(tmp_path: Path) -> None:
|
||||||
|
"""THE LOAD-BEARING ARM — the gate is the CARRIER, not the enum.
|
||||||
|
|
||||||
|
An accessor that returns three tokens which nothing propagates is a vocabulary, not a seam. So
|
||||||
|
the assertion is on what the tool actually hands back: a payload carrying the state. A carrier
|
||||||
|
that returned the concept without its state would leave ``adjudication_for`` correct and the
|
||||||
|
hypothesiser none the wiser, which is the exact shape this step exists to close.
|
||||||
|
"""
|
||||||
|
bundle = tmp_path / "bundle"
|
||||||
|
bundle.mkdir()
|
||||||
|
(bundle / "a.md").write_text(
|
||||||
|
"---\ntype: concept\nadjudication: proposed\n---\nbody\n", encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
carrier = tools.make_adjudication_tool(str(bundle))
|
||||||
|
payload = tools.adjudication_payload(str(bundle), "a.md")
|
||||||
|
|
||||||
|
assert payload["adjudication"] == "proposed"
|
||||||
|
assert payload["concept"] == "a.md"
|
||||||
|
assert carrier.name == "concept_adjudication_state"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_carrier_is_a_library_primitive_not_wired_into_the_run(tmp_path: Path) -> None:
|
||||||
|
"""``run.py`` must not import the carrier: wiring it into the run surface would move the
|
||||||
|
byte-pinned demo transcript, and would make the state reachable only by reading a run's output
|
||||||
|
rather than by calling a function."""
|
||||||
|
run_source = (
|
||||||
|
Path(__file__).resolve().parents[1] / "src" / "portfolio_optimiser" / "run.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert "make_adjudication_tool" not in run_source
|
||||||
|
assert "adjudication_payload" not in run_source
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue