Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
### Added

- **Novelty review controls.** These are the checks a reviewer asked of the novelty 2x2.
- `paired` now reports `added_over_placebo` when the `placebo` arm ran: right with the artifact, wrong without it, and wrong with the placebo.
- A new `no_artifact_repeat` arm asks the baseline again as a new cache draw. With it, `paired` reports `noise_floor`, `added_net_of_noise` and `degraded_within_noise`.
- `novelty analyze` and `novelty probe` take `--context-framing reference|neutral`, which controls for placebo abstention caused by the prompt.

### Changed

- **`ci95_added` resamples atoms rather than probes** (`ci_cluster: "atom"`). Paraphrases of one atom are correlated, so the old interval was too narrow.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clustered interval needs 5 atoms where the old one needed 5 probes, and the fragment does not say so.

A run of 4 atoms with 3 paraphrases each used to get ci95_added; now it gets null. The new intervals are also wider, so ci95_added from older cells is not comparable with new ones.

Suggestion: add both to this entry, so a reader knows what to re-run.

- The `shuffle_text` docstring no longer cites the study's shuffled-arm win, because that arm injected more files than `full` did.
- `prompts_sha256` now also covers the two context preambles, so its value differs from earlier runs.
42 changes: 40 additions & 2 deletions dokimi/docs/reference/cli-interface.json
Original file line number Diff line number Diff line change
Expand Up @@ -2602,7 +2602,7 @@
"nargs": null,
"position": 0,
"required": true,
"type": "pathlib._local.Path#661d9498960e"
"type": "pathlib.Path#683f66497b37"
},
{
"action": "_StoreAction",
Expand Down Expand Up @@ -2650,7 +2650,7 @@
"nargs": null,
"position": null,
"required": true,
"type": "pathlib._local.Path#661d9498960e"
"type": "pathlib.Path#683f66497b37"
},
{
"action": "_StoreAction",
Expand Down Expand Up @@ -9220,6 +9220,25 @@
"required": false,
"type": "dokimi.cli.build_parser.<locals>.positive_int#faf57db5ff9f"
},
{
"action": "_StoreAction",
"choices": [
"'neutral'",
"'reference'"
],
"const": null,
"default": "reference",
"group_key": null,
"group_required": null,
"hidden": false,
"is_flag": false,
"key": "--context-framing",
"kind": "option",
"nargs": null,
"position": null,
"required": false,
"type": null
},
{
"action": "_StoreTrueAction",
"choices": null,
Expand Down Expand Up @@ -10221,6 +10240,25 @@
"required": false,
"type": "dokimi.cli.build_parser.<locals>.positive_int#faf57db5ff9f"
},
{
"action": "_StoreAction",
"choices": [
"'neutral'",
"'reference'"
],
"const": null,
"default": "reference",
"group_key": null,
"group_required": null,
"hidden": false,
"is_flag": false,
"key": "--context-framing",
"kind": "option",
"nargs": null,
"position": null,
"required": false,
"type": null
},
{
"action": "_StoreAction",
"choices": null,
Expand Down
36 changes: 36 additions & 0 deletions dokimi/docs/skill-novelty.md
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,42 @@ of writing its own.
Read the output as a statement about the *measurement*, not the atom: a non-discriminating
atom can be true and simply universal. A **negative** `r` is the one to treat as a defect.

## Is `added` above placebo and above noise

Three controls bound the headline `added` rate. Each one is added to the `paired` block
only when its arm ran, so a two-arm run reads exactly as before.

- **Placebo-gated `added`** (`added_over_placebo`, needs the `placebo` arm). This counts a
probe only when it is right with the artifact, wrong without it, *and* wrong with the
placebo text. A gain the placebo also gets is a context effect, not knowledge.
- **Noise floor** (`noise_floor`, needs the `no_artifact_repeat` arm). That arm asks every
baseline question again as a new sample (a new cache `draw`). Its flips against the

baseline are what `added` and `degraded` would be if the artifact did nothing.
- `added_net_of_noise` subtracts the floor's `added` from the measured one.
- `degraded_within_noise` is `true` when `degraded` is no larger than the floor's own
`degraded`. Do not report such a `degraded` as a defect.
- In the review rerun the floor was 5–10% in each direction, and every model's
`degraded` fell inside it.
- Both are point estimates; the floor carries its own `ci95_added` to read them against.
The floor is only as wide as the answerer's sampling: at temperature 0 it can read 0.
An injected transport is not given the `draw`, so there the repeat is no new sample.
- **Atom-clustered interval.** The paraphrases of one atom succeed or fail together, so
`ci95_added` (and the placebo-gated `ci95`) resamples atoms, not probes
(`ci_cluster: "atom"`). A probe-level bootstrap reads three correlated wordings as three
data points, so its interval comes out too narrow. A record without `ci_cluster` predates this
change and its interval is probe-level.

`--context-framing neutral` changes the preamble from "Reference material:" to "Below is a
document. It may or may not be relevant to the question." Use it to check whether placebo
abstention is caused by the prompt. In the rerun the framing did not move the `full` arm on
any model (±0.02). With the placebo, one model's abstention fell from 0.845 to 0.703. The
framing is recorded in the run config and is covered by `prompts_sha256`.

```bash
python cli.py novelty analyze --artifact skill:s --model M --arms no_artifact,full,placebo,no_artifact_repeat --placebo-text other-domain.md --context-framing neutral

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The line breaks of this command were lost (--model M --arms ... --context-framing). Put it on separate lines with \ continuations, like the other examples.

```

## Comparing knowledge: which matcher is allowed to answer

Two steps ask "is this the same knowledge?" — the retention oracle ("does the shortened text still
Expand Down
13 changes: 9 additions & 4 deletions dokimi/dokimi/commands/novelty.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def cmd_novelty_analyze(args: argparse.Namespace) -> None:
n_arms = len(_arms(args.arms))
estimate["expected_usd"] = _estimate_usd(
args, n_tokens, estimate["estimate"]["by_role"], settings.tier,
context_share=(n_arms - 1) / n_arms,
context_share=(n_arms - 1 - ("no_artifact_repeat" in args.arms)) / n_arms,
Comment on lines 84 to +87
)
# Every arm but the baseline is priced as carrying the artifact; a placebo of another
# length is not read.
Expand Down Expand Up @@ -119,7 +119,7 @@ def cmd_novelty_analyze(args: argparse.Namespace) -> None:
budgets=tuple(args.budgets),
concurrency=args.concurrency,
allow_self_judge=args.allow_self_judge,
placebo_text=placebo_text,
placebo_text=placebo_text, context_framing=args.context_framing,
cache_dir=Path(args.cache_dir) if args.cache_dir else None,
seed=args.seed,
include_resources=args.include_resources,
Expand Down Expand Up @@ -227,7 +227,7 @@ def cmd_novelty_probe(args: argparse.Namespace) -> None:
atoms, probe_set.probes, verdict.text, answerer=answerer, judge=judge,
arms=_arms(args.arms), concurrency=args.concurrency,
allow_self_judge=args.allow_self_judge, seed=args.seed,
placebo_text=placebo_text,
placebo_text=placebo_text, framing=args.context_framing,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Must-fix — Impact: saved probe results cannot distinguish the reference and neutral framing experiments, undermining comparison of the placebo-abstention control. Trigger: run novelty probe with --context-framing neutral and save its output. Evidence: framing is passed only to measurement; build_probe_record() writes no framing/config field, and the saved runs contain questions and responses, not the context preamble. Thus neither the output nor its artifact digest identifies the chosen framing. Persist the framing in the probe record (and test both choices). Workaround: use analyze, which records neutral framing, or track probe flags externally. Severity justification: medium because this loses experiment provenance for the newly supported probe control, without affecting unrelated measurements.

)
except ValueError as exc: # a model failure under way
raise SystemExit(str(exc)) from None
Expand Down Expand Up @@ -814,7 +814,12 @@ def _add_model_args(parser: argparse.ArgumentParser, positive_int) -> None:
help="Output-token ceiling. Raise for reasoning models, which can "
"spend the provider default on reasoning and return empty text")
parser.add_argument("--arms", default="no_artifact,full",
help="Arms to run: no_artifact,full[,placebo][,shuffled]")
help="Arms to run: no_artifact,full[,placebo][,shuffled]"
"[,no_artifact_repeat] (the last is the noise floor)")
parser.add_argument("--context-framing", default="reference", dest="context_framing",
choices=("reference", "neutral"),
help="How the context is introduced: as reference material, or as a "
"document that may be irrelevant (the placebo framing control)")
parser.add_argument("--placebo-text", default=None, dest="placebo_text",
help="File of domain-mismatched text for the placebo control: it "
"separates 'this artifact carries knowledge' from 'this model "
Expand Down
6 changes: 5 additions & 1 deletion dokimi/dokimi/novelty/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ class AnalyzeConfig:
# cited file, so a run that skips this must not present attribution as confirmed.
confirm_carriers: bool = DEFAULT_SETTINGS["confirm_carriers"]
placebo_text: str | None = None
context_framing: str = "reference"
cache_dir: Path | None = None
seed: int = 0
# Measured over the study corpus, 0.72 of a skill's words live outside `SKILL.md`, so
Expand All @@ -81,7 +82,10 @@ def _prompts_sha256() -> str:

def _run_config(config: "AnalyzeConfig") -> dict:
"""What decided this cell's numbers beyond the models: two cells compare only if these match."""
framing = {} if config.context_framing == "reference" else {
"context_framing": config.context_framing}
return {
**framing,
"seed": config.seed,
"max_atoms": config.max_atoms,
"paraphrases": config.paraphrases,
Expand Down Expand Up @@ -244,7 +248,7 @@ def analyze(
atom_list, probe_list, text,
answerer=answerer, judge=judge, arms=config.arms,
concurrency=config.concurrency, allow_self_judge=config.allow_self_judge,
placebo_text=config.placebo_text, seed=config.seed,
placebo_text=config.placebo_text, seed=config.seed, framing=config.context_framing,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No test pins that analyze passes the framing to the measurement or records it in the run config.

Mutation check: removing , framing=config.context_framing here, and separately changing framing = {} if config.context_framing == "reference" else {...} (analyze.py:85) to if True, both pass the new tests and the 357 neighbouring novelty tests (analyze, measure, cli, report, gate). With the first mutant, novelty analyze --context-framing neutral silently runs the reference prompt. With the second, cells with different framings look comparable. The flag exists only for the placebo-abstention control.

Suggestion: add a test that runs analyze with context_framing="neutral" and a recording transport, asserts the prompt contains the neutral preamble, and asserts run_config holds context_framing: "neutral" while the default run has no such key.

)
if config.allow_self_judge and same_client(judge, answerer):
warnings.append(
Expand Down
127 changes: 127 additions & 0 deletions dokimi/dokimi/novelty/controls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Copyright (C) 2026 Intel Corporation
# SPDX-License-Identifier: Apache-2.0

"""Controls that bound the novelty 2x2: placebo gating, a noise floor, atom-clustered CIs.

Each answers a reviewer question the raw ``added`` rate cannot: is the gain just context
(placebo), is it above run-to-run noise (a repeat of the baseline), and how wide is the
interval when the paraphrases of one atom are not independent (resample atoms).
"""

from __future__ import annotations

import random

# A second no-artifact answer of the same probe, sampled as a new draw. Its flips against
# the baseline are what ``added`` and ``degraded`` would be if the artifact did nothing.
REPEAT_ARM = "no_artifact_repeat"
PLACEBO_ARM = "placebo"


def _key(run) -> tuple[str, str, str]:
return (run.atom_id, run.probe_kind, run.question)


def _usable(runs, arm: str) -> dict:
return {_key(r): r for r in runs
if r.arm == arm and r.probe_kind != "reversal" and not r.errored}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The guards and the interval bounds that decide reported measurements are untested.

Mutation check, each passing the full new test file and the neighbouring novelty tests:

  • _usable no longer excluding errored runs (controls.py:24): errored placebo and repeat answers then count as wrong and inflate added_over_placebo.count and noise_floor.added.
  • Dropping the with_arm == PLACEBO_ARM guard (controls.py:58).
  • Flipping degraded <= floor in degraded_within_noise: the test data has degraded == floor degraded, so only equality is pinned.
  • Swapping the floor's ci95_added flips to right-then-wrong.
  • Changing the < 5 cluster threshold to < 8, the lo/hi percentile indices, the seed, or the cluster weighting. The tests only check "wider than flat" and None for very few clusters.

Suggestion: add cases with an errored probe in the placebo and repeat arms, a case with degraded strictly below the floor, and one exact ci95 for a fixed small input, plus a 5-cluster case that must return an interval.



def clustered_ci(
per_atom: dict[str, list[float]], *, iterations: int = 400, seed: int = 0
) -> list[float] | None:
"""Percentile bootstrap of a probe rate that resamples atoms, not probes.

The paraphrases of one atom succeed or fail together, so a probe-level bootstrap
reads three correlated wordings as three data points and comes out too narrow.
"""
clusters = [values for values in per_atom.values() if values]
if len(clusters) < 5:
return None
rng = random.Random(f"novelty-ci-{seed}")
n = len(clusters)
means = []
for _ in range(iterations):
picked = [clusters[rng.randrange(n)] for _ in range(n)]
total = sum(len(c) for c in picked)
means.append(sum(sum(c) for c in picked) / total)
means.sort()
lo = means[int(0.025 * iterations)]
hi = means[min(iterations - 1, int(0.975 * iterations))]
return [round(lo, 4), round(hi, 4)]


def placebo_gated(runs, *, baseline_arm: str, with_arm: str, scoring: str) -> dict | None:
"""``added`` that also beats the placebo: right with the artifact, wrong without it,
and wrong with domain-mismatched text of the same length.

``None`` when no placebo arm ran. Only probes answered in all three arms count.
"""
placebo = _usable(runs, PLACEBO_ARM)
if not placebo or with_arm == PLACEBO_ARM:
return None
baseline, with_runs = _usable(runs, baseline_arm), _usable(runs, with_arm)
per_atom: dict[str, list[float]] = {}
for key, run in with_runs.items():
before, ctrl = baseline.get(key), placebo.get(key)
if before is None or ctrl is None:
continue
gated = run.correct(scoring) and not before.correct(scoring) and not ctrl.correct(scoring)
per_atom.setdefault(run.atom_id, []).append(1.0 if gated else 0.0)
n = sum(len(v) for v in per_atom.values())
count = int(sum(sum(v) for v in per_atom.values()))
return {
"n_paired": n,
"count": count,
"rate": round(count / n, 4) if n else 0.0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added_over_placebo reports rate: 0.0 when no probe was answered in all three arms.

With full errored on the probes the placebo answered (or the reverse), paired_outcomes returns {"n_paired": 0, "count": 0, "rate": 0.0, "ci95": None}, which reads as "no gain beats the placebo". Nothing was measured. noise_floor already returns None for this case.

Suggestion: "rate": round(count / n, 4) if n else None (or omit the key), and a test with no three-arm overlap.

"ci95": clustered_ci(per_atom),
}


def noise_floor(runs, *, baseline_arm: str, scoring: str) -> dict | None:
"""Flip rates between two no-artifact answers of the same probe.

``added`` here is wrong-then-right and ``degraded`` right-then-wrong with no artifact
at all; a with-artifact rate inside this floor is not evidence. ``None`` without the
repeat arm.
"""
repeat = _usable(runs, REPEAT_ARM)
if not repeat:
return None
baseline = _usable(runs, baseline_arm)
added = degraded = n = 0
flips: dict[str, list[float]] = {}
for key, run in repeat.items():
before = baseline.get(key)
if before is None:
continue
n += 1
first, second = before.correct(scoring), run.correct(scoring)
added += second and not first
degraded += first and not second
flips.setdefault(run.atom_id, []).append(1.0 if second and not first else 0.0)
if not n:
return None
return {
"n_paired": n,
"draws": 1,
"added": round(added / n, 4),
"degraded": round(degraded / n, 4),
"ci95_added": clustered_ci(flips),
}


def control_fields(runs, *, baseline_arm: str, with_arm: str, scoring: str,
rates: dict) -> dict:
"""The control keys :func:`~dokimi.novelty.measure.paired_outcomes` adds when the
placebo or the repeat arm ran; empty otherwise, so old outputs are unchanged."""
fields: dict = {}
gated = placebo_gated(runs, baseline_arm=baseline_arm, with_arm=with_arm, scoring=scoring)
if gated is not None:
fields["added_over_placebo"] = gated
floor = noise_floor(runs, baseline_arm=baseline_arm, scoring=scoring)
if floor is not None:
fields["noise_floor"] = floor
fields["added_net_of_noise"] = round(rates["added"] - floor["added"], 4)
fields["degraded_within_noise"] = rates["degraded"] <= floor["degraded"]
Comment on lines +125 to +126

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Must-fix — Impact: these derived metrics can report noise-adjusted gains or dismiss degradation using unrelated probe populations. Trigger: asymmetric full/repeat arm errors. Evidence: paired_outcomes intersects baseline with full, whereas noise_floor independently intersects baseline with repeat. For two initially wrong probes, let full answer a correctly and error on b, while repeat errors on a and answers b wrongly: this reports added_net_of_noise=1.0 despite having no successfully answered three-arm comparison. The same population mismatch affects degraded_within_noise. Compute both compared rates on the common baseline/full/repeat intersection, and omit the derived metrics when that intersection is empty; retain the standalone floor if useful. Workaround: use these derived fields only when all three arms have the same usable probes. Severity justification: medium because this is a bounded but material measurement correctness failure. Add an asymmetric-error regression test; the current tests cover only complete populations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and the empty case is worse: when every full answer errors, rates are the 0.0 placeholders and the output still says added_net_of_noise: -0.1, degraded_within_noise: true (n_paired 0, floor 0.1/0.05). That is a pass for a degradation nobody measured. Computing both on the common baseline/full/repeat probes and omitting the two fields when that set is empty fixes both cases.

return fields
Loading