diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c30113..aaf3b6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,24 @@ date-based pre-1.0 versions.
## [Unreleased]
+## [0.1.28] — 2026-09-10
+
+Fixes from the v0.1.27 published-image test.
+
+### Fixed
+- A malformed KPI pin (empty field, unknown op, missing or non-numeric bound, `min > max`,
+ empty or non-list `allowed`, unknown mode or target) is now refused when authored, as the
+ docs said; v0.1.27 accepted it and skipped it at grade time, so the KPI never ran. The spec
+ holds the one rule the author gate and the grade share.
+- KPI contracts stack per flag, one per field, and never displace a flag's other checks; a new
+ version of the same KPI replaces its predecessor. Other contracts stay one per flag.
+- An upload through the attach button ticks the rail's Load step without a reload.
+- The scorecard read no longer says the floor's blocks were defects "the reviewers missed"; the
+ count includes blocks a reviewer also raised.
+
+### Added
+- ⌘K "Add a check or KPI contract" opens the contract builder without the assistant.
+
## [0.1.27] — 2026-09-10
KPI contracts the floor proves before any judge, and OpenTelemetry traces as cases.
diff --git a/apps/bff/app.py b/apps/bff/app.py
index 5d721ad..264f21a 100644
--- a/apps/bff/app.py
+++ b/apps/bff/app.py
@@ -6521,7 +6521,9 @@ def _put_grounding_contract(
"version": version,
}
contracts = ontology.get("verification_contracts") or []
- idx = next((i for i, c in enumerate(contracts) if c.get("flag_code") == flag_code), None)
+ # KPI-PINS-1: KPI contracts stack per flag (one per field); others stay one-per-flag;
+ # identity never includes the version (a new version replaces its predecessor).
+ idx = _grounding.contract_slot(contracts, entry)
replaced = idx is not None
if replaced:
contracts[idx] = entry
diff --git a/apps/shell/src/app.jsx b/apps/shell/src/app.jsx
index 45df1ab..69bd243 100644
--- a/apps/shell/src/app.jsx
+++ b/apps/shell/src/app.jsx
@@ -741,6 +741,9 @@ function App({ theme: themeProp, setTheme: setThemeProp, mode, setMode } = {}) {
id: `edit-judge-${role}`, label: `Edit reviewer ${role} — lens, prompt, model, calibrate`,
run: () => { try { window.dispatchEvent(new CustomEvent("lithrim:edit-judge", { detail: { role } })); } catch {} },
})),
+ // KPI-PINS-1: pin a fact-check or a KPI contract without the assistant (the contract builder
+ // inline; Add contract stays the audited write).
+ { id: "add-contract", label: "Add a check or KPI contract — a fact-check or a KPI the floor proves before any judge", run: () => { try { window.dispatchEvent(new CustomEvent("lithrim:add-contract")); } catch {} } },
// UI-JOURNEY-1 (B10): how the council decides — the frozen rules in plain words ($0 read).
{ id: "show-council-rules", label: "How the council decides — evidence, tiers, the hesitant judge, the floor", run: () => { try { window.dispatchEvent(new CustomEvent("lithrim:show-council-rules")); } catch {} } },
{ id: "show-workspace", label: `Show workspace — what ${activeWs} holds`, run: () => { try { window.dispatchEvent(new CustomEvent("lithrim:show-workspace", { detail: { workspace: activeWs } })); } catch {} } },
diff --git a/apps/shell/src/genui/IngestPreviewCard.casechange.test.jsx b/apps/shell/src/genui/IngestPreviewCard.casechange.test.jsx
new file mode 100644
index 0000000..28709d9
--- /dev/null
+++ b/apps/shell/src/genui/IngestPreviewCard.casechange.test.jsx
@@ -0,0 +1,24 @@
+/* IngestPreviewCard.casechange.test.jsx — KPI-PINS-1: an approved upload announces the corpus
+ change, so the rail's Load step ticks without a reload. */
+import { describe, it, expect, vi } from "vitest";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+
+vi.mock("../bff.js", () => ({
+ ingestPreview: vi.fn(),
+ ingestCommit: vi.fn().mockResolvedValue({ count: 4, mapping_id: null, native: true }),
+}));
+
+import IngestPreviewCard from "./IngestPreviewCard.jsx";
+
+describe("IngestPreviewCard — approve announces the corpus change", () => {
+ it("dispatches lithrim:cases-changed with the commit result", async () => {
+ const heard = [];
+ const on = (e) => heard.push(e.detail);
+ window.addEventListener("lithrim:cases-changed", on);
+ render();
+ fireEvent.click(screen.getByTestId("ingest-approve"));
+ await waitFor(() => expect(heard).toHaveLength(1));
+ expect(heard[0]).toMatchObject({ count: 4 });
+ window.removeEventListener("lithrim:cases-changed", on);
+ });
+});
diff --git a/apps/shell/src/genui/IngestPreviewCard.jsx b/apps/shell/src/genui/IngestPreviewCard.jsx
index 31082f7..2fd8946 100644
--- a/apps/shell/src/genui/IngestPreviewCard.jsx
+++ b/apps/shell/src/genui/IngestPreviewCard.jsx
@@ -57,6 +57,8 @@ export default function IngestPreviewCard({
const res = await ingestCommit({ approved_template: prev.template, raw, fmt, filename, extraction_rules: rules, agent });
setState({ phase: "loaded", msg: `${res.count} case${res.count === 1 ? "" : "s"} loaded into the corpus` });
onLoaded?.(res);
+ // KPI-PINS-1: the rail's Load step and the case browser re-read the corpus now, not on reload.
+ try { window.dispatchEvent(new CustomEvent("lithrim:cases-changed", { detail: res })); } catch {}
onResult?.({ ingested: res.count, mapping_id: res.mapping_id });
} catch (e) {
setState({ phase: "error", msg: friendlyError(e) });
diff --git a/apps/shell/src/genui/ScorecardCard.jsx b/apps/shell/src/genui/ScorecardCard.jsx
index df54618..c822ecf 100644
--- a/apps/shell/src/genui/ScorecardCard.jsx
+++ b/apps/shell/src/genui/ScorecardCard.jsx
@@ -398,7 +398,7 @@ export default function ScorecardCard({ cases = [], flag = {}, units = null, ver
Deterministic floor
{floor.cleared} cleared
-
{floor.enforced} enforced
+
{floor.enforced} enforced
{floor.inconclusive} cannot-ground
{(floor.gold_defect_clears || []).length === 0
?
0 genuine defects cleared ✓
diff --git a/apps/shell/src/genui/reportRead.js b/apps/shell/src/genui/reportRead.js
index d5b2759..555b250 100644
--- a/apps/shell/src/genui/reportRead.js
+++ b/apps/shell/src/genui/reportRead.js
@@ -107,7 +107,9 @@ export function scorecardRead(p = {}) {
if (clause) sentences.push("That noise is expected, it is why the floor exists.");
}
const parts = [];
- if (enforced) parts.push(`enforced ${enforced} real defect${enforced === 1 ? "" : "s"} the reviewers missed`);
+ // The count covers every block a deterministic check enforced, including ones the reviewers also
+ // raised, so it names what the floor proved, not what the reviewers missed.
+ if (enforced) parts.push(`enforced ${enforced} defect${enforced === 1 ? "" : "s"} a deterministic check proved`);
if (cleared) parts.push(`cleared ${cleared} false alarm${cleared === 1 ? "" : "s"}`);
parts.push(genuine
? `cleared ${genuine} genuine defect${genuine === 1 ? "" : "s"}, investigate before trusting this run`
diff --git a/apps/shell/src/genui/reportRead.test.js b/apps/shell/src/genui/reportRead.test.js
index f2fe33e..0c160d3 100644
--- a/apps/shell/src/genui/reportRead.test.js
+++ b/apps/shell/src/genui/reportRead.test.js
@@ -33,7 +33,7 @@ describe("scorecardRead — the cohort read", () => {
"3 of 14 notes passed clean, 7 were flagged, 4 need a human look. "
+ "On their own, the three reviewers matched the answer key just 39% of the time: they disagree and they over-flag. "
+ "That noise is expected, it is why the floor exists. "
- + "The deterministic floor enforced 8 real defects the reviewers missed, cleared 2 false alarms, and cleared zero genuine defects. "
+ + "The deterministic floor enforced 8 defects a deterministic check proved, cleared 2 false alarms, and cleared zero genuine defects. "
+ "The floor lifted verdict accuracy from 39% to 54%. "
+ "Both sides of that number use the same scoring rule, so the lift is the floor's alone.",
);
@@ -134,7 +134,8 @@ describe("scorecardRead — the cohort read", () => {
});
expect(r.hero).toBeNull();
expect(r.text).not.toMatch(/%/);
- expect(r.text).toMatch(/The deterministic floor enforced 8 real defects/);
+ expect(r.text).toMatch(/The deterministic floor enforced 8 defects a deterministic check proved/);
+ expect(r.text).not.toMatch(/reviewers missed/); // KPI-PINS-1: the count includes blocks a reviewer also raised
});
it("never emits an em or en dash", () => {
diff --git a/apps/shell/src/panes.jsx b/apps/shell/src/panes.jsx
index a2cd58a..290f2d6 100644
--- a/apps/shell/src/panes.jsx
+++ b/apps/shell/src/panes.jsx
@@ -430,9 +430,12 @@ export function CenterPane({ onOpenArtifact, onOpenCaseRun, artifactOpen, onRunE
if (!role) return;
setChat((c) => [...c, { role: "assistant", text: "", parts: [{ type: "tool-judge_editor", state: "output-available", output: { role, agent } }] }]);
};
+ // KPI-PINS-1: the contract builder card inline (no assistant needed).
+ const onAddContract = () => setChat((c) => [...c, { role: "assistant", text: "", parts: [{ type: "tool-contract_builder", state: "output-available", output: { agent } }] }]);
+ window.addEventListener("lithrim:add-contract", onAddContract);
window.addEventListener("lithrim:create-judge", onCreate);
window.addEventListener("lithrim:edit-judge", onEdit);
- return () => { window.removeEventListener("lithrim:create-judge", onCreate); window.removeEventListener("lithrim:edit-judge", onEdit); };
+ return () => { window.removeEventListener("lithrim:add-contract", onAddContract); window.removeEventListener("lithrim:create-judge", onCreate); window.removeEventListener("lithrim:edit-judge", onEdit); };
}, [agent]);
// UI-JOURNEY-1 (B10): the ⌘K "How the council decides" trigger — GET /v1/council/rules ($0)
// rendered as the tool-council_rules card inline.
diff --git a/apps/shell/src/ui_journey.test.jsx b/apps/shell/src/ui_journey.test.jsx
index 11d28e0..8810c96 100644
--- a/apps/shell/src/ui_journey.test.jsx
+++ b/apps/shell/src/ui_journey.test.jsx
@@ -279,3 +279,18 @@ describe("B10b configure from the shell: the reviewer builder and editor open fr
unmount();
});
});
+
+
+describe("KPI-PINS-1: KPI contracts from the shell", () => {
+ it("the palette's Add a check or KPI contract renders the contract builder with the KPI types", async () => {
+ localStorage.setItem("lithrim.workspace.chosen", "default");
+ stubFetch({ "/v1/grounding-contract/types": { contract_types: ["field_in_set", "kpi_threshold", "value_grounding"], pack: "_core" } });
+ const { unmount } = render(
{}} />);
+ await screen.findByTitle("Switch workspace");
+ openPalette();
+ fireEvent.click(await screen.findByTestId("cmdk-item-add-contract"));
+ expect((await screen.findAllByText("Fact-check")).length).toBeGreaterThan(0);
+ expect(screen.getByLabelText("params json")).toBeInTheDocument();
+ unmount();
+ });
+});
diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml
index 6e2d2dd..d21894d 100644
--- a/deploy/docker-compose.yml
+++ b/deploy/docker-compose.yml
@@ -34,7 +34,7 @@
services:
bff:
- image: ${LITHRIM_BFF_IMAGE:-ghcr.io/lithrim-dev/lithrim-bff:v0.1.27}
+ image: ${LITHRIM_BFF_IMAGE:-ghcr.io/lithrim-dev/lithrim-bff:v0.1.28}
ports:
- "8787:8787"
environment:
@@ -90,7 +90,7 @@ services:
- lithrim
ui:
- image: ${LITHRIM_UI_IMAGE:-ghcr.io/lithrim-dev/lithrim-ui:v0.1.27}
+ image: ${LITHRIM_UI_IMAGE:-ghcr.io/lithrim-dev/lithrim-ui:v0.1.28}
ports:
- "5180:5180"
environment:
diff --git a/docker-compose.yml b/docker-compose.yml
index 167e3af..8a69ae4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -22,7 +22,7 @@ services:
bff:
# Published image (GHCR). Override the tag/ref with LITHRIM_BFF_IMAGE; to build locally instead,
# restore a `build: {context: ., dockerfile: Dockerfile.bff}` block.
- image: ${LITHRIM_BFF_IMAGE:-ghcr.io/lithrim-dev/lithrim-bff:v0.1.27}
+ image: ${LITHRIM_BFF_IMAGE:-ghcr.io/lithrim-dev/lithrim-bff:v0.1.28}
ports:
- "8787:8787"
environment:
@@ -113,7 +113,7 @@ services:
# NOTE: VITE_BFF_URL is baked into the JS bundle at BUILD time — this image serves whatever
# origin it was built with (expected http://localhost:8787). Pointing the UI at a different BFF
# origin requires a rebuild, not a runtime env var.
- image: ${LITHRIM_UI_IMAGE:-ghcr.io/lithrim-dev/lithrim-ui:v0.1.27}
+ image: ${LITHRIM_UI_IMAGE:-ghcr.io/lithrim-dev/lithrim-ui:v0.1.28}
ports:
- "5180:5180"
environment:
diff --git a/docs/KPI_CONTRACTS.md b/docs/KPI_CONTRACTS.md
index eb011f9..0367d91 100644
--- a/docs/KPI_CONTRACTS.md
+++ b/docs/KPI_CONTRACTS.md
@@ -17,11 +17,17 @@ artifact itself when it is JSON (`target: artifact`).
| `field_in_set` | `field`, `allowed` (list), `mode` `in` (default) or `not_in`, `case_insensitive` (default true) | a recorded floor pass | the pinned flag is injected; evidence names the allowed set and the actual value | field absent, no record, prose source |
Unknown is never a violation and never a silent pass: it is surfaced as an inconclusive floor
-result the reviewer can see. A malformed pin (an unknown `op`, a missing bound, `min > max`, an
-empty `allowed`) is refused when the contract is authored, not at grade time.
+result the reviewer can see. A malformed pin (an empty `field`, an unknown `op`, a missing or
+non-numeric bound, `min > max`, an empty or non-list `allowed`, an unknown `mode` or `target`)
+is refused when the contract is authored; the grade applies the same rule.
-Author one from the shell with the contract builder (the type list is the pack's registered
-executors), or in the ontology:
+KPI contracts **stack**: a flag can carry several, one per `field` (a latency KPI and a token KPI
+on the same flag), and pinning one never displaces the flag's other checks. A pin with the same
+flag, type and field as an existing one replaces it, whatever its version, so a flag never runs
+two versions of one KPI. Every other contract type stays one per flag, as before.
+
+Author one from the shell with **⌘K → Add a check or KPI contract** (the contract builder; the
+type list is the pack's registered executors), or in the ontology:
```json
{
diff --git a/lithrim_bench/__init__.py b/lithrim_bench/__init__.py
index 1c26c73..75be382 100644
--- a/lithrim_bench/__init__.py
+++ b/lithrim_bench/__init__.py
@@ -1,3 +1,3 @@
"""Lithrim: deterministically-labeled clinical-AI benchmark generator."""
-__version__ = "0.1.27"
+__version__ = "0.1.28"
diff --git a/lithrim_bench/harness/grounding.py b/lithrim_bench/harness/grounding.py
index c78559d..04d6e24 100644
--- a/lithrim_bench/harness/grounding.py
+++ b/lithrim_bench/harness/grounding.py
@@ -1216,6 +1216,31 @@ def _shape_arguments(self, case: dict[str, Any], finding: dict[str, Any]) -> dic
_HTTP_CONTRACT_TYPES = {"kb_grounding", "web_search"}
+# KPI-PINS-1: contract types that STACK per flag, one instance per value of the named param (a
+# flag can carry a latency KPI and a token KPI at once). Every other type is one-per-flag. A
+# contract's identity for replacement is (flag_code, contract_type, that param) for these, and
+# (flag_code) among the one-per-flag types otherwise; never the version, so a new version of a
+# check replaces its predecessor and a flag never runs two versions of one check.
+STACKING_CONTRACT_KEYS: dict[str, str] = {"kpi_threshold": "field", "field_in_set": "field"}
+
+
+def contract_slot(contracts: list[dict], entry: dict) -> int | None:
+ """The index of the contract ``entry`` replaces in ``contracts``, or None to append."""
+ ctype = entry.get("contract_type")
+ flag = entry.get("flag_code")
+ key = STACKING_CONTRACT_KEYS.get(ctype)
+ for i, c in enumerate(contracts):
+ if c.get("flag_code") != flag:
+ continue
+ other = c.get("contract_type")
+ if key is not None:
+ if other == ctype and (c.get("params") or {}).get(key) == (entry.get("params") or {}).get(key):
+ return i
+ elif other not in STACKING_CONTRACT_KEYS:
+ return i
+ return None
+
+
@dataclass(frozen=True)
class FloorExecutor:
"""One structural-floor executor: how to build its tool + its pinned reference.
diff --git a/lithrim_bench/verification/spec.py b/lithrim_bench/verification/spec.py
index e25c40b..949e9db 100644
--- a/lithrim_bench/verification/spec.py
+++ b/lithrim_bench/verification/spec.py
@@ -168,6 +168,65 @@
}
+KPI_OPS = ("<", "<=", ">", ">=", "==", "!=", "between")
+_KPI_TARGETS = ("source", "artifact")
+
+
+def kpi_number(value: object) -> float | None:
+ """A pinned bound or a record cell as a number, or None when it is not one (bools are not)."""
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, (int, float)):
+ return float(value)
+ if isinstance(value, str):
+ try:
+ return float(value.strip())
+ except ValueError:
+ return None
+ return None
+
+
+def kpi_threshold_expected(ref: dict) -> dict:
+ """KPI-PINS-1: validate a ``kpi_threshold`` pin and return the comparison it encodes. The one
+ rule the author-time gate (via ``VerificationSpec``) and the grade-time tool both apply, so
+ a pin that would be skipped at grade time is refused when it is authored."""
+ if not str(ref.get("field") or "").strip():
+ raise ValueError("kpi_threshold needs a non-empty field (a dotted path into the record)")
+ if str(ref.get("target") or "source") not in _KPI_TARGETS:
+ raise ValueError(f"kpi_threshold target must be one of {_KPI_TARGETS}")
+ op = str(ref.get("op") or "")
+ if op not in KPI_OPS:
+ raise ValueError(f"kpi_threshold op must be one of {KPI_OPS}, got {op!r}")
+ if op == "between":
+ lo, hi = kpi_number(ref.get("min")), kpi_number(ref.get("max"))
+ if lo is None:
+ raise ValueError("kpi_threshold op 'between' needs a numeric min")
+ if hi is None:
+ raise ValueError("kpi_threshold op 'between' needs a numeric max")
+ if lo > hi:
+ raise ValueError("kpi_threshold 'between' needs min <= max")
+ return {"op": op, "min": lo, "max": hi}
+ val = kpi_number(ref.get("value"))
+ if val is None:
+ raise ValueError(f"kpi_threshold op {op!r} needs a numeric value")
+ return {"op": op, "value": val}
+
+
+def field_in_set_allowed(ref: dict) -> tuple[list, str, bool]:
+ """KPI-PINS-1: validate a ``field_in_set`` pin; return (allowed, mode, case_insensitive)."""
+ if not str(ref.get("field") or "").strip():
+ raise ValueError("field_in_set needs a non-empty field (a dotted path into the record)")
+ if str(ref.get("target") or "source") not in _KPI_TARGETS:
+ raise ValueError(f"field_in_set target must be one of {_KPI_TARGETS}")
+ allowed = ref.get("allowed")
+ if not isinstance(allowed, list) or not allowed:
+ raise ValueError("field_in_set needs a non-empty 'allowed' list")
+ mode = str(ref.get("mode") or "in")
+ if mode not in ("in", "not_in"):
+ raise ValueError("field_in_set mode must be 'in' or 'not_in'")
+ return list(allowed), mode, bool(ref.get("case_insensitive", True))
+
+
@dataclass(frozen=True)
class Claim:
"""The unit under verification: a council flag's assertion about a span of an artifact."""
@@ -223,6 +282,10 @@ def __post_init__(self) -> None:
missing = _REQUIRED_REFERENCE_KEYS[self.tool] - set(self.reference)
if missing:
raise ValueError(f"{self.tool} spec missing reference keys: {sorted(missing)}")
+ if self.tool == TOOL_KPI_THRESHOLD:
+ kpi_threshold_expected(self.reference)
+ if self.tool == TOOL_FIELD_IN_SET:
+ field_in_set_allowed(self.reference)
if self.tool == TOOL_STRUCTURAL_JUTE:
sel = self.reference.get("mapping_selector")
if not (isinstance(sel, dict) and sel.get("by") in {"title", "id"} and "value" in sel):
diff --git a/lithrim_bench/verification/tools.py b/lithrim_bench/verification/tools.py
index 914c8b8..4824cb1 100644
--- a/lithrim_bench/verification/tools.py
+++ b/lithrim_bench/verification/tools.py
@@ -44,6 +44,9 @@
Claim,
VerificationResult,
VerificationSpec,
+ field_in_set_allowed,
+ kpi_number,
+ kpi_threshold_expected,
)
@@ -1274,23 +1277,6 @@ def _dig_path(record: dict, dotted: str) -> Any:
# --------------------------------------------------------------------------- #
# KPI-FLOOR-1: deterministic record-field checks (threshold / range, allowed set)
# --------------------------------------------------------------------------- #
-_KPI_OPS = ("<", "<=", ">", ">=", "==", "!=", "between")
-
-
-def _kpi_number(value: Any) -> float | None:
- """A record cell as a number, or None when it is not one (bools are not numbers here)."""
- if isinstance(value, bool):
- return None
- if isinstance(value, (int, float)):
- return float(value)
- if isinstance(value, str):
- try:
- return float(value.strip())
- except ValueError:
- return None
- return None
-
-
def _kpi_record(claim: Claim, ref: dict, manifest: dict) -> tuple[dict | None, VerificationResult | None]:
"""The structured object a KPI check reads: the case's record source (``source_kind: record``,
JSON at ``source_path``) by default, or the artifact itself when ``target: artifact``. Returns
@@ -1329,20 +1315,7 @@ class KpiThresholdTool(VerificationTool):
@staticmethod
def expected(ref: dict) -> dict:
- op = str(ref.get("op") or "")
- if op not in _KPI_OPS:
- raise ValueError(f"kpi_threshold op must be one of {_KPI_OPS}, got {op!r}")
- if op == "between":
- lo, hi = _kpi_number(ref.get("min")), _kpi_number(ref.get("max"))
- if lo is None or hi is None:
- raise ValueError("kpi_threshold op 'between' needs numeric min and max")
- if lo > hi:
- raise ValueError("kpi_threshold 'between' needs min <= max")
- return {"op": op, "min": lo, "max": hi}
- val = _kpi_number(ref.get("value"))
- if val is None:
- raise ValueError(f"kpi_threshold op {op!r} needs a numeric value")
- return {"op": op, "value": val}
+ return kpi_threshold_expected(ref)
def verify(self, claim: Claim, spec: VerificationSpec) -> VerificationResult:
ref = spec.reference
@@ -1361,7 +1334,7 @@ def verify(self, claim: Claim, spec: VerificationSpec) -> VerificationResult:
if out is not None:
return out
raw_actual = _dig_path(record, field_name)
- actual = _kpi_number(raw_actual)
+ actual = kpi_number(raw_actual)
evidence: dict[str, Any] = {"field": field_name, **exp, "actual": raw_actual}
if raw_actual is None:
evidence["reason"] = "the field is absent from the record (unknown, not a violation)"
@@ -1398,13 +1371,7 @@ class FieldInSetTool(VerificationTool):
@staticmethod
def allowed(ref: dict) -> tuple[list, str, bool]:
- allowed = ref.get("allowed")
- if not isinstance(allowed, list) or not allowed:
- raise ValueError("field_in_set needs a non-empty 'allowed' list")
- mode = str(ref.get("mode") or "in")
- if mode not in ("in", "not_in"):
- raise ValueError("field_in_set mode must be 'in' or 'not_in'")
- return list(allowed), mode, bool(ref.get("case_insensitive", True))
+ return field_in_set_allowed(ref)
def verify(self, claim: Claim, spec: VerificationSpec) -> VerificationResult:
ref = spec.reference
diff --git a/pyproject.toml b/pyproject.toml
index 452ad63..bb60228 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "lithrim"
-version = "0.1.27"
+version = "0.1.28"
description = "Self-hostable evaluation harness for AI agents — a tool-grounded verification floor under the LLM judge. Backs the Lithrim research paper and developer product."
authors = [{name = "Lithrim"}]
readme = "README.md"
diff --git a/tests/test_kpi_pins.py b/tests/test_kpi_pins.py
new file mode 100644
index 0000000..fa94687
--- /dev/null
+++ b/tests/test_kpi_pins.py
@@ -0,0 +1,238 @@
+"""KPI-PINS-1: the fixes the v0.1.27 published-image test found.
+
+1. A malformed KPI pin (unknown op, missing or non-numeric bound, reversed range, empty or
+ non-list allowed set, unknown mode) is refused at AUTHOR time, as docs/KPI_CONTRACTS.md says,
+ instead of being accepted and then skipped as malformed at grade time (the KPI silently never
+ ran). The spec validates it, so the author gate and the grade share one rule.
+3. KPI contracts stack per flag, one per field: pinning a KPI never displaces a flag's other
+ checks, a second field appends, and a new version of the same KPI (same flag, type and field)
+ replaces its predecessor, so a flag never runs two versions of one check. An ordinary
+ contract keeps the existing rule (re-saving a flag replaces its contract) and never displaces
+ a KPI contract.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+from lithrim_bench.harness.config import Agent, Dataset, EvalProfile, save_agent
+from lithrim_bench.harness.grounding import validate_contract_params
+from lithrim_bench.harness.ontology import VerificationContractDecl
+from lithrim_bench.verification import TOOL_FIELD_IN_SET, TOOL_KPI_THRESHOLD, VerificationSpec
+
+REPO_ROOT = Path(__file__).resolve().parents[1]
+_BASE = {
+ "inject_flag_code": "UNSUPPORTED_ASSERTION",
+ "inject_severity": "HIGH",
+ "artifact_kind": "llm_response",
+}
+
+
+def _decl(ctype, params, version="v1", flag="UNSUPPORTED_ASSERTION"):
+ return VerificationContractDecl(
+ flag_code=flag,
+ question="q",
+ contract_type=ctype,
+ version=version,
+ params={**params, **_BASE},
+ )
+
+
+# ── 1. author-time refusal ────────────────────────────────────────────────────
+@pytest.mark.parametrize(
+ "ctype, params, why",
+ [
+ ("kpi_threshold", {"field": "duration_ms", "op": "<="}, "value"),
+ ("kpi_threshold", {"field": "duration_ms", "op": "<=", "value": "fast"}, "value"),
+ ("kpi_threshold", {"field": "duration_ms", "op": "~", "value": 1}, "op"),
+ ("kpi_threshold", {"field": "x", "op": "between", "min": 5, "max": 1}, "min"),
+ ("kpi_threshold", {"field": "x", "op": "between", "min": 5}, "max"),
+ ("kpi_threshold", {"field": "", "op": "<=", "value": 1}, "field"),
+ ("field_in_set", {"field": "status.code", "allowed": []}, "allowed"),
+ ("field_in_set", {"field": "status.code", "allowed": "OK"}, "allowed"),
+ ("field_in_set", {"field": "status.code", "allowed": ["OK"], "mode": "maybe"}, "mode"),
+ (
+ "field_in_set",
+ {"field": "status.code", "allowed": ["OK"], "target": "elsewhere"},
+ "target",
+ ),
+ ],
+)
+def test_a_malformed_kpi_pin_is_refused_when_authored(ctype, params, why):
+ with pytest.raises(ValueError, match=why):
+ validate_contract_params(_decl(ctype, params), pack="_core")
+
+
+def test_well_formed_kpi_pins_pass_the_author_gate():
+ validate_contract_params(
+ _decl("kpi_threshold", {"field": "duration_ms", "op": "<=", "value": 2000}), pack="_core"
+ )
+ validate_contract_params(
+ _decl("kpi_threshold", {"field": "t", "op": "between", "min": "20", "max": 800}),
+ pack="_core",
+ )
+ validate_contract_params(
+ _decl("field_in_set", {"field": "status.code", "allowed": ["OK"], "mode": "not_in"}),
+ pack="_core",
+ )
+
+
+def test_the_spec_is_the_one_rule_the_grade_also_applies():
+ with pytest.raises(ValueError, match="op"):
+ VerificationSpec(
+ tool=TOOL_KPI_THRESHOLD,
+ applies_to_flags=("X",),
+ locus="",
+ reference={"field": "a", "op": "=~", "value": 1},
+ )
+ with pytest.raises(ValueError, match="allowed"):
+ VerificationSpec(
+ tool=TOOL_FIELD_IN_SET,
+ applies_to_flags=("X",),
+ locus="",
+ reference={"field": "a", "allowed": []},
+ )
+
+
+# ── 3. KPI contracts stack per flag, identity without version ────────────────
+AGENT = "kpi_pins_agent"
+CORE_ONT = REPO_ROOT / "packs" / "_core" / "ontology.json"
+
+
+@pytest.fixture
+def ctx(tmp_path, monkeypatch):
+ pytest.importorskip("fastapi", reason="needs the [bff] extra")
+ _bff = REPO_ROOT / "apps" / "bff"
+ if str(_bff) not in sys.path:
+ sys.path.insert(0, str(_bff))
+ import app as bff
+
+ from lithrim_bench.harness import workspace as _workspace
+
+ monkeypatch.setattr(
+ _workspace,
+ "get_active_workspace",
+ lambda: _workspace.Workspace(name="default", pack="_core"),
+ )
+ db = tmp_path / "config.sqlite"
+ save_agent(
+ Agent(
+ name=AGENT,
+ eval_profile=EvalProfile(
+ judges=("risk_judge",),
+ council_config={},
+ ontology_ref="_core/1",
+ ontology_path=str(CORE_ONT),
+ tools=(),
+ kb_bindings={},
+ severity_map_ref="ontology:_core/1",
+ ),
+ dataset=Dataset(case_id="c", source="s", baseline="b"),
+ ),
+ db_path=db,
+ )
+ workdir = tmp_path / "ont"
+ c = bff._build_tool_context(
+ req_agent=AGENT,
+ db_path=db,
+ out_dir=tmp_path / "out",
+ workdir=workdir,
+ collections_db=tmp_path / "coll.sqlite",
+ actor=bff.Actor(type="system", id="test"),
+ x_actor=None,
+ )
+ return c, workdir
+
+
+def _contracts(workdir, flag):
+ hits = list(Path(workdir).rglob("*.json"))
+ docs = [json.loads(p.read_text()) for p in hits]
+ ont = next(d for d in docs if isinstance(d, dict) and "verification_contracts" in d)
+ return [
+ (c["contract_type"], (c.get("params") or {}).get("field"), c["version"])
+ for c in ont["verification_contracts"]
+ if c["flag_code"] == flag
+ ]
+
+
+def _pin(c, ctype, params, version, flag="SOURCE_CONTRADICTION"):
+ return c.put_grounding_contract(
+ flag_code=flag,
+ contract_type=ctype,
+ question=f"{ctype} {params.get('field')}",
+ version=version,
+ agent=AGENT,
+ params={
+ **params,
+ "inject_flag_code": flag,
+ "inject_severity": "HIGH",
+ "artifact_kind": "llm_response",
+ },
+ )
+
+
+def test_kpi_pins_stack_per_field_and_never_displace_other_checks(ctx):
+ c, workdir = ctx
+ r1 = _pin(c, "kpi_threshold", {"field": "duration_ms", "op": "<=", "value": 2000}, "latency/1")
+ assert r1["replaced"] is False
+ after = _contracts(workdir, "SOURCE_CONTRADICTION")
+ assert ("value_grounding", None, "value-grounding/3") in after, "the pack's own check stays"
+ assert ("kpi_threshold", "duration_ms", "latency/1") in after
+ r2 = _pin(
+ c,
+ "kpi_threshold",
+ {"field": "gen_ai.usage.output_tokens", "op": "between", "min": 20, "max": 800},
+ "tokens/1",
+ )
+ assert r2["replaced"] is False
+ r3 = _pin(c, "field_in_set", {"field": "status.code", "allowed": ["OK"]}, "status/1")
+ assert r3["replaced"] is False
+ kpis = [
+ x
+ for x in _contracts(workdir, "SOURCE_CONTRADICTION")
+ if x[0] in ("kpi_threshold", "field_in_set")
+ ]
+ assert sorted(kpis) == [
+ ("field_in_set", "status.code", "status/1"),
+ ("kpi_threshold", "duration_ms", "latency/1"),
+ ("kpi_threshold", "gen_ai.usage.output_tokens", "tokens/1"),
+ ]
+
+
+def test_a_new_version_of_the_same_kpi_replaces_its_predecessor(ctx):
+ c, workdir = ctx
+ _pin(c, "kpi_threshold", {"field": "duration_ms", "op": "<=", "value": 2000}, "latency/1")
+ r = _pin(c, "kpi_threshold", {"field": "duration_ms", "op": "<=", "value": 1500}, "latency/2")
+ assert r["replaced"] is True
+ latency = [x for x in _contracts(workdir, "SOURCE_CONTRADICTION") if x[1] == "duration_ms"]
+ assert latency == [("kpi_threshold", "duration_ms", "latency/2")], (
+ "one version of one check, never both"
+ )
+
+
+def test_an_ordinary_contract_keeps_replace_by_flag_and_never_displaces_a_kpi(ctx):
+ c, workdir = ctx
+ _pin(
+ c,
+ "kpi_threshold",
+ {"field": "duration_ms", "op": "<=", "value": 2000},
+ "latency/1",
+ flag="FABRICATED_CLAIM",
+ )
+ r = c.put_grounding_contract(
+ flag_code="FABRICATED_CLAIM",
+ contract_type="source_grounding",
+ params={"source_path": "transcript"},
+ question="grounded?",
+ version="source-grounding/2",
+ agent=AGENT,
+ )
+ assert r["replaced"] is True, "the flag's existing ordinary check is replaced, as before"
+ got = _contracts(workdir, "FABRICATED_CLAIM")
+ assert ("source_grounding", None, "source-grounding/2") in got
+ assert ("kpi_threshold", "duration_ms", "latency/1") in got
+ assert sum(1 for x in got if x[0] == "source_grounding") == 1