Repository navigation
novelty: placebo-gated added, noise floor, atom-clustered CI, context framing #418
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
f129d9a
4e970e1
2c9223f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. | ||
| - 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. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: The line breaks of this command were lost ( |
||
| ``` | ||
|
|
||
| ## Comparing knowledge: which matcher is allowed to answer | ||
|
|
||
| Two steps ask "is this the same knowledge?" — the retention oracle ("does the shortened text still | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| ) | ||
| except ValueError as exc: # a model failure under way | ||
| raise SystemExit(str(exc)) from None | ||
|
|
@@ -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 " | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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, | ||
|
|
@@ -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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No test pins that Mutation check: removing Suggestion: add a test that runs |
||
| ) | ||
| if config.allow_self_judge and same_client(judge, answerer): | ||
| warnings.append( | ||
|
|
||
| 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} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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:
Suggestion: add cases with an errored probe in the placebo and repeat arms, a case with |
||
|
|
||
|
|
||
| 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, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With Suggestion: |
||
| "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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, and the empty case is worse: when every |
||
| return fields | ||
There was a problem hiding this comment.
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 getsnull. The new intervals are also wider, soci95_addedfrom older cells is not comparable with new ones.Suggestion: add both to this entry, so a reader knows what to re-run.