Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion apps/bff/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions apps/shell/src/app.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {} } },
Expand Down
24 changes: 24 additions & 0 deletions apps/shell/src/genui/IngestPreviewCard.casechange.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<IngestPreviewCard fmt="otel" count={4} sample_cases={[{ case_id: "otel_a" }]} raw="{}" filename="t.json" />);
fireEvent.click(screen.getByTestId("ingest-approve"));
await waitFor(() => expect(heard).toHaveLength(1));
expect(heard[0]).toMatchObject({ count: 4 });
window.removeEventListener("lithrim:cases-changed", on);
});
});
2 changes: 2 additions & 0 deletions apps/shell/src/genui/IngestPreviewCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) });
Expand Down
2 changes: 1 addition & 1 deletion apps/shell/src/genui/ScorecardCard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,7 @@ export default function ScorecardCard({ cases = [], flag = {}, units = null, ver
<div className="mb-1 font-semibold text-foreground">Deterministic floor</div>
<div className="flex flex-wrap gap-x-3 font-[family-name:var(--font-mono)]">
<span style={{ color: "var(--teal)" }} title="False alarms the floor disproved and cleared.">{floor.cleared} cleared</span>
<span style={{ color: "var(--accent)" }} title="Blocks the floor enforced that the reviewers missed.">{floor.enforced} enforced</span>
<span style={{ color: "var(--accent)" }} title="Blocks a deterministic check enforced (proved defects, whether or not a reviewer also raised them).">{floor.enforced} enforced</span>
<span className="text-muted-foreground" title="Checks that declined to vote — nothing checkable (a feature, not a failure).">{floor.inconclusive} cannot-ground</span>
{(floor.gold_defect_clears || []).length === 0
? <span style={{ color: "var(--teal)" }} title="The safety property: the floor never cleared a genuine (answer-key) defect.">0 genuine defects cleared ✓</span>
Expand Down
4 changes: 3 additions & 1 deletion apps/shell/src/genui/reportRead.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
5 changes: 3 additions & 2 deletions apps/shell/src/genui/reportRead.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
);
Expand Down Expand Up @@ -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", () => {
Expand Down
5 changes: 4 additions & 1 deletion apps/shell/src/panes.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions apps/shell/src/ui_journey.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App mode="shell" setMode={() => {}} />);
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();
});
});
4 changes: 2 additions & 2 deletions deploy/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 10 additions & 4 deletions docs/KPI_CONTRACTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
2 changes: 1 addition & 1 deletion lithrim_bench/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Lithrim: deterministically-labeled clinical-AI benchmark generator."""

__version__ = "0.1.27"
__version__ = "0.1.28"
25 changes: 25 additions & 0 deletions lithrim_bench/harness/grounding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions lithrim_bench/verification/spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Expand Down
Loading
Loading