diff --git a/README.md b/README.md index 27a9f06..36becc1 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,10 @@ their outputs. You can also create your own model-graded evaluations with Autoevals. It's easy to add custom prompts, parse outputs, and manage exceptions. +To measure whether repeated categorical judgments agree on the same cases, use +the offline [judge consistency utilities](docs/JUDGE_CONSISTENCY.md). Both Python +and TypeScript report agreement, missing coverage and optional reference accuracy. +
### Requirements diff --git a/docs/JUDGE_CONSISTENCY.md b/docs/JUDGE_CONSISTENCY.md new file mode 100644 index 0000000..e8d7b8d --- /dev/null +++ b/docs/JUDGE_CONSISTENCY.md @@ -0,0 +1,188 @@ +# Repeated judge consistency + +Similar average scores can hide different behavior: one judge can repeatedly +change its answer on the same case while another gives stable answers. The +consistency utilities analyze **one categorical criterion** over an explicit +case-by-rater panel. A rater can mean a named model, human annotator, or fixed +repeat slot of the same judge. This is an offline analysis API, not a Scorer +that sends another model request. + +Consistency is not correctness. A panel can agree perfectly on an incorrect +answer. Optional reference labels provide a separate accuracy report. Shared +errors, correlated repeat slots and unrepresentative benchmarks remain study +design concerns; the library cannot establish an independent ground truth. + +## Python + +```python +from autoevals import ( + ConsistencyGate, ConsistencyPlan, Rating, analyze_consistency, + consistency_scores, render_consistency_markdown, +) + +plan = ConsistencyPlan( + score_name="answer_supported", + raters=("repeat-1", "repeat-2", "repeat-3"), + categories=("no", "yes"), + cases=("case-1", "case-2"), + gates=(ConsistencyGate("raw_agreement", minimum=0.9),), +) +ratings = [ + Rating("case-1", "repeat-1", "yes"), + Rating("case-1", "repeat-2", "yes"), + Rating("case-1", "repeat-3", "no"), + Rating("case-2", "repeat-1", status="error"), +] +report = analyze_consistency( + ratings, plan=plan, references={"case-1": "yes", "case-2": "no"} +) +print(render_consistency_markdown(report)) +scores = consistency_scores(report) +``` + +## TypeScript + +```typescript +import { + analyzeConsistency, + consistencyScores, + renderConsistencyMarkdown, +} from "autoevals"; + +const report = analyzeConsistency( + [ + { case_id: "case-1", rater_id: "repeat-1", category: "yes" }, + { case_id: "case-1", rater_id: "repeat-2", category: "yes" }, + { case_id: "case-1", rater_id: "repeat-3", category: "no" }, + { case_id: "case-2", rater_id: "repeat-1", status: "error" }, + ], + { + plan: { + score_name: "answer_supported", + raters: ["repeat-1", "repeat-2", "repeat-3"], + categories: ["no", "yes"], + cases: ["case-1", "case-2"], + gates: [{ statistic: "raw_agreement", minimum: 0.9 }], + }, + references: { "case-1": "yes", "case-2": "no" }, + }, +); +console.log(renderConsistencyMarkdown(report)); +const scores = consistencyScores(report); +``` + +Both examples return an `inconclusive` requirement verdict: the missing +observations prevent the stated support and coverage requirements from being +met. They are not silently discarded to make the panel look reliable. + +## Collecting native Scores + +Use `ScoreMapping` and `rating_from_score` in Python, or `ratingFromScore` with a +`ScoreMapping` in TypeScript. Supply exact numeric/category pairs, for example +`[(0, "no"), (1, "yes")]` / `[[0, "no"], [1, "yes"]]`. The adapter checks the +Score name and rejects unmapped scores, booleans and numeric strings. It does +not infer thresholds or round continuous scores into labels. Define any +discretization policy explicitly in your application before using this API. + +A native Score with `score=None` / `null` becomes `missing`. A non-null legacy +Score `error` takes precedence over its numeric value and becomes `error`. +Modern scorers throw exceptions: catch them in your collection code and call +`error_rating` / `errorRating`. Error messages and provider metadata are never +copied into a Rating. Do not use the same rater slot twice for a case; record a +new repeat with a different declared rater identity. + +An explicit `cases` manifest retains cases for which **no judge returned**. +Omitting it uses the observed union and records `cohort_provenance=observed_union`; +the API cannot discover entirely absent cases. References and groups must be +subsets of the cohort, so use a manifest if they include unobserved cases. +`missing`, `error` and an absent cell have separate counts. Only `scored` cells +enter categorical statistics. Inputs must use valid Unicode strings; identifiers +are compared exactly and sorted by UTF-8 bytes without Unicode normalization. + +## Statistics and denominators + +| Output | Supporting observations | Interpretation | +| -------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | +| Raw agreement | Cases with at least two scored ratings | Mean per-case ordered-pair agreement; each supporting case has equal weight | +| Fleiss kappa | Complete declared panels only | Fixed-panel coefficient with pooled category marginals | +| Nominal alpha | All cases with at least two scored ratings | Krippendorff nominal coincidence weighting; singleton ratings are excluded from both observed and expected disagreement | +| Pairwise Cohen kappa | Overlap for that fixed rater pair | Each marginal uses the same pairwise overlap; confusion cells are sparse | +| Consensus accuracy | Reference-labeled cases with a decided consensus | Conditional accuracy, reported together with labeled/evaluated counts and reference coverage | + +Each coefficient includes supporting cases and ratings, denominator cases, +weighting, and an explicit undefined reason. Zero expected disagreement is +undefined, not perfect agreement. A constant-varying pair may have defined +zero Cohen kappa. Negative coefficients remain negative. + +Fleiss follows the fixed-panel formulation in +[statsmodels](https://www.statsmodels.org/stable/generated/statsmodels.stats.inter_rater.fleiss_kappa.html). +Nominal alpha follows the coincidence construction in +[Krippendorff, Computing Alpha-Reliability](https://www.asc.upenn.edu/sites/default/files/2021-03/Computing%20Krippendorff's%20Alpha-Reliability.pdf). +The shared tests include that paper's missing-data example. + +Consensus requires strictly more than half the **declared** rater slots to +select one category, plus `minimum_consensus_ratings` (default 2) scored +ratings. Missing slots cannot make one remaining judge unanimous. Ties, +insufficient ratings, and absence of a declared-panel majority are distinct +abstention reasons. Reference labels never break a tie. + +## Requirements, groups and diagnostic intervals + +Predeclare `groups` as named lists of case IDs; `overall` is reserved. Groups +may overlap. A gate selects one of `raw_agreement`, `fleiss_kappa`, +`nominal_alpha`, or `consensus_accuracy`, a `minimum` value, and optionally a +group. Defaults require at least two supporting cases, 100% statistic coverage +and 100% scored-cell coverage. Override them explicitly for a different study. +Accuracy's statistic coverage denominator is reference-labeled cases, not all +cases; its rating coverage denominator still includes every planned cell. + +An undefined value or insufficient support/coverage produces `inconclusive`. +Only sufficiently supported observations can pass or fail their value threshold. +Any failed gate makes the overall verdict `fail`; otherwise any inconclusive +gate makes it `inconclusive`. With no gates the verdict is `not_evaluated`. +These are requirements on the supplied finite benchmark, not population-level +confidence claims or a universal definition of an acceptable judge. + +`ConsistencyBootstrap` / the plan's `bootstrap` enables marginal diagnostic +percentile intervals (`resamples=0` disables them). Whole cases, including their +missingness and references, are resampled with replacement. Duplicate sampled +occurrences remain distinct. Each group's UTF-8 name and the configured seed +initialize a specified FNV1a/xorshift32 generator identically in both languages; +global RNG state is untouched. Intervals use linear interpolation between +ordered samples. Defaults require at least 20 supporting cases and at least +20 expected draws in each tail. Any undefined draw or degenerate distribution +makes the interval unavailable, with attempted/valid/invalid counts retained. +Intervals never determine gate decisions and are not simultaneous intervals +across multiple groups or statistics. + +## Replaying and sharing evidence + +`input_to_json` / `inputToJSON` serializes selected observations, the normalized +plan and references. `analyze_consistency_json` / `analyzeConsistencyJSON` +validates that versioned bundle and recomputes its report offline. Unknown +fields, duplicate keys (including escaped duplicates), invalid Unicode and +nonfinite values are rejected. The wire format uses snake_case in both +languages. `report_to_json` / `reportToJSON` exports a bounded finite report; +report export is not a signature or authenticity proof. + +`consistency_scores` / `consistencyScores` exposes agreement, rating coverage +and consensus accuracy as native Scores. Undefined values stay null. Signed +kappa and alpha are not squeezed into Score's [0,1] range. Compact support +metadata is included; full case-level report metadata requires explicit +`include_report=True` / `{ includeReport: true }`. Case IDs, labels and reference +answers can themselves be sensitive even when provider metadata is excluded. + +`ConsistencyLimits` is application-owned and is not read from portable input. +Default ceilings cover rating count, panel dimensions, group membership work, +pairwise and bootstrap work, selected bytes, JSON bytes/depth/nodes and report +details. Analysis checks declared dimensions before reading observations and +checks cumulative work before constructing matrices or sampling. `detail_limit` +is shared across overall and group sections, so omitted cases are counted. +Callers processing unusually large studies should set their own limits and +partition the study intentionally. Runtime depends on cases, raters, groups +and resamples; the API performs no scheduling or concurrent model execution. + +Runnable offline examples are in `examples/judge_consistency.py` and +`examples/judge_consistency.ts`. They exercise native Score adaptation, +unavailable results, requirements, replay and summary projection without +requiring API credentials. diff --git a/examples/judge_consistency.py b/examples/judge_consistency.py new file mode 100644 index 0000000..02db421 --- /dev/null +++ b/examples/judge_consistency.py @@ -0,0 +1,53 @@ +"""Run with uv run python examples/judge_consistency.py; no model calls.""" + +from autoevals import ( + ConsistencyGate, + ConsistencyPlan, + Score, + ScoreMapping, + analyze_consistency, + analyze_consistency_json, + consistency_scores, + error_rating, + input_to_json, + rating_from_score, + render_consistency_markdown, +) + + +def main(): + plan = ConsistencyPlan( + "supported", + ("repeat-1", "repeat-2", "repeat-3"), + ("no", "yes"), + cases=("case-1", "case-2", "case-3"), + gates=(ConsistencyGate("raw_agreement", 0.9),), + ) + mapping = ScoreMapping("supported", ((0, "no"), (1, "yes"))) + # Substitute stored native Scores from your existing judge calls here. + stored = { + "case-1": (1, 1, 1), + "case-2": (0, 1, 0), + "case-3": (None, 1, None), + } + ratings = [] + for case_id, values in stored.items(): + for rater_id, value in zip(plan.raters, values): + if case_id == "case-3" and rater_id == "repeat-3": + ratings.append(error_rating(case_id, rater_id)) + else: + score = Score("supported", value, metadata={"rationale": "not exported"}) + ratings.append(rating_from_score(case_id, rater_id, score, mapping=mapping)) + references = {"case-1": "yes", "case-2": "no", "case-3": "yes"} + report = analyze_consistency(ratings, plan=plan, references=references) + bundle = input_to_json(ratings, plan=plan, references=references) + assert analyze_consistency_json(bundle) == report + assert "rationale" not in bundle + assert report["verdict"] == "inconclusive" + print(render_consistency_markdown(report)) + for score in consistency_scores(report): + print(score.name, score.score) + + +if __name__ == "__main__": + main() diff --git a/examples/judge_consistency.ts b/examples/judge_consistency.ts new file mode 100644 index 0000000..7d3e803 --- /dev/null +++ b/examples/judge_consistency.ts @@ -0,0 +1,60 @@ +/** Run with pnpm exec tsx examples/judge_consistency.ts. No model calls. */ +import { + analyzeConsistency, + analyzeConsistencyJSON, + consistencyScores, + errorRating, + inputToJSON, + ratingFromScore, + renderConsistencyMarkdown, + type ConsistencyPlan, + type Rating, + type Score, + type ScoreMapping, +} from "autoevals"; + +const plan: ConsistencyPlan = { + score_name: "supported", + raters: ["repeat-1", "repeat-2", "repeat-3"], + categories: ["no", "yes"], + cases: ["case-1", "case-2", "case-3"], + gates: [{ statistic: "raw_agreement", minimum: 0.9 }], +}; +const mapping: ScoreMapping = { + score_name: "supported", + entries: [ + [0, "no"], + [1, "yes"], + ], +}; +const stored: [string, (number | null)[]][] = [ + ["case-1", [1, 1, 1]], + ["case-2", [0, 1, 0]], + ["case-3", [null, 1, null]], +]; +const ratings: Rating[] = []; +for (const [caseId, values] of stored) { + plan.raters.forEach((raterId, i) => { + if (caseId === "case-3" && raterId === "repeat-3") { + ratings.push(errorRating(caseId, raterId)); + } else { + const score: Score = { + name: "supported", + score: values[i], + metadata: { rationale: "not exported" }, + }; + ratings.push(ratingFromScore(caseId, raterId, score, mapping)); + } + }); +} +const references = { "case-1": "yes", "case-2": "no", "case-3": "yes" }; +const report = analyzeConsistency(ratings, { plan, references }); +const bundle = inputToJSON(ratings, { plan, references }); +if (JSON.stringify(analyzeConsistencyJSON(bundle)) !== JSON.stringify(report)) + throw new Error("replay changed evidence"); +if (bundle.includes("rationale")) + throw new Error("unexpected provider metadata"); +if (report.verdict !== "inconclusive") + throw new Error("missing coverage must not pass"); +console.log(renderConsistencyMarkdown(report)); +console.log(consistencyScores(report)); diff --git a/fixtures/consistency/studies.json b/fixtures/consistency/studies.json new file mode 100644 index 0000000..5c810fe --- /dev/null +++ b/fixtures/consistency/studies.json @@ -0,0 +1,1054 @@ +[ + { + "name": "complete disagreement", + "input": { + "format": "autoevals.consistency.input", + "version": 1, + "plan": { + "score_name": "judge", + "raters": [ + "a", + "b" + ], + "categories": [ + "no", + "yes" + ] + }, + "ratings": [ + { + "case_id": "0", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "0", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "1", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "1", + "rater_id": "b", + "category": "yes" + } + ], + "references": {} + }, + "expected": { + "verdict": "not_evaluated", + "groups": [ + { + "statistics": { + "raw_agreement": { + "value": 0 + }, + "fleiss_kappa": { + "value": -1 + }, + "nominal_alpha": { + "value": -0.5 + } + }, + "consensus": { + "decided_cases": 0, + "abstained_cases": 2 + } + } + ] + } + }, + { + "name": "declared missing cohort", + "input": { + "format": "autoevals.consistency.input", + "version": 1, + "plan": { + "score_name": "judge", + "raters": [ + "a", + "b" + ], + "categories": [ + "no", + "yes" + ], + "cases": [ + "0", + "1", + "2" + ], + "groups": [ + { + "name": "absent", + "cases": [ + "2" + ] + } + ], + "gates": [ + { + "statistic": "raw_agreement", + "minimum": 0.9 + } + ] + }, + "ratings": [ + { + "case_id": "0", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "0", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "1", + "rater_id": "a", + "category": "no" + } + ], + "references": { + "0": "yes", + "1": "no", + "2": "yes" + } + }, + "expected": { + "verdict": "inconclusive", + "groups": [ + { + "coverage": { + "expected_cells": 6, + "scored_cells": 3, + "absent_cells": 3 + }, + "consensus": { + "reference": { + "accuracy": 1, + "coverage": 0.3333333333333333 + } + } + }, + { + "coverage": { + "zero_rating_cases": 1 + } + } + ] + } + }, + { + "name": "prototype and unicode", + "input": { + "format": "autoevals.consistency.input", + "version": 1, + "plan": { + "score_name": "judge", + "raters": [ + "__proto__", + "constructor" + ], + "categories": [ + "no", + "yes" + ], + "cases": [ + "", + "𐀀", + "__proto__" + ] + }, + "ratings": [ + { + "case_id": "__proto__", + "rater_id": "constructor", + "category": "yes" + }, + { + "case_id": "__proto__", + "rater_id": "__proto__", + "status": "error" + } + ], + "references": { + "__proto__": "yes" + } + }, + "expected": { + "groups": [ + { + "coverage": { + "scored_cells": 1, + "error_cells": 1, + "absent_cells": 4 + } + } + ] + } + }, + { + "name": "all unavailable", + "input": { + "format": "autoevals.consistency.input", + "version": 1, + "plan": { + "score_name": "judge", + "raters": [ + "a", + "b" + ], + "categories": [ + "no", + "yes" + ] + }, + "ratings": [ + { + "case_id": "x", + "rater_id": "a", + "status": "missing" + }, + { + "case_id": "x", + "rater_id": "b", + "status": "error" + } + ], + "references": {} + }, + "expected": { + "groups": [ + { + "statistics": { + "raw_agreement": { + "value": null + }, + "fleiss_kappa": { + "value": null + }, + "nominal_alpha": { + "value": null + } + }, + "coverage": { + "rating_coverage": 0 + } + } + ] + } + }, + { + "name": "seeded repeated cases", + "input": { + "format": "autoevals.consistency.input", + "version": 1, + "plan": { + "score_name": "judge", + "raters": [ + "a", + "b" + ], + "categories": [ + "no", + "yes" + ], + "bootstrap": { + "resamples": 500, + "confidence": 0.8, + "minimum_cases": 2, + "seed": 17 + } + }, + "ratings": [ + { + "case_id": "0", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "0", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "1", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "1", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "2", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "2", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "3", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "3", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "4", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "4", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "5", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "5", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "6", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "6", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "7", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "7", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "8", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "8", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "9", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "9", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "10", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "10", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "11", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "11", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "12", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "12", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "13", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "13", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "14", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "14", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "15", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "15", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "16", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "16", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "17", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "17", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "18", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "18", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "19", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "19", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "20", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "20", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "21", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "21", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "22", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "22", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "23", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "23", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "24", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "24", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "25", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "25", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "26", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "26", + "rater_id": "b", + "category": "no" + }, + { + "case_id": "27", + "rater_id": "a", + "category": "no" + }, + { + "case_id": "27", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "28", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "28", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "29", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "29", + "rater_id": "b", + "category": "no" + } + ], + "references": {} + }, + "expected": { + "groups": [ + { + "coverage": { + "complete_cases": 30 + }, + "statistics": { + "raw_agreement": { + "value": 0.3333333333333333 + } + } + } + ] + } + }, + { + "name": "zero cohort", + "input": { + "format": "autoevals.consistency.input", + "version": 1, + "plan": { + "score_name": "judge", + "raters": [ + "a", + "b" + ], + "categories": [ + "no", + "yes" + ], + "cases": [] + }, + "ratings": [], + "references": {} + }, + "expected": { + "groups": [ + { + "coverage": { + "expected_cells": 0, + "rating_coverage": null + } + } + ] + } + }, + { + "name": "fractional agreement threshold 15", + "input": { + "format": "autoevals.consistency.input", + "version": 1, + "plan": { + "score_name": "judge", + "raters": [ + "a", + "b", + "c" + ], + "categories": [ + "no", + "yes" + ], + "gates": [ + { + "statistic": "raw_agreement", + "minimum": 0.3333333333333333 + } + ] + }, + "ratings": [ + { + "case_id": "0", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "0", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "0", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "1", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "1", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "1", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "2", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "2", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "2", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "3", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "3", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "3", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "4", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "4", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "4", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "5", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "5", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "5", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "6", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "6", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "6", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "7", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "7", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "7", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "8", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "8", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "8", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "9", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "9", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "9", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "10", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "10", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "10", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "11", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "11", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "11", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "12", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "12", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "12", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "13", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "13", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "13", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "14", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "14", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "14", + "rater_id": "c", + "category": "no" + } + ] + }, + "expected": { + "verdict": "pass" + } + }, + { + "name": "fractional agreement threshold 10", + "input": { + "format": "autoevals.consistency.input", + "version": 1, + "plan": { + "score_name": "judge", + "raters": [ + "a", + "b", + "c" + ], + "categories": [ + "no", + "yes" + ], + "gates": [ + { + "statistic": "raw_agreement", + "minimum": 0.33333333333333337 + } + ] + }, + "ratings": [ + { + "case_id": "0", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "0", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "0", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "1", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "1", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "1", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "2", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "2", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "2", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "3", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "3", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "3", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "4", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "4", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "4", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "5", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "5", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "5", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "6", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "6", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "6", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "7", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "7", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "7", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "8", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "8", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "8", + "rater_id": "c", + "category": "no" + }, + { + "case_id": "9", + "rater_id": "a", + "category": "yes" + }, + { + "case_id": "9", + "rater_id": "b", + "category": "yes" + }, + { + "case_id": "9", + "rater_id": "c", + "category": "no" + } + ] + }, + "expected": { + "verdict": "fail" + } + } +] diff --git a/js/consistency-fixtures.test.ts b/js/consistency-fixtures.test.ts new file mode 100644 index 0000000..2575025 --- /dev/null +++ b/js/consistency-fixtures.test.ts @@ -0,0 +1,31 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { analyzeConsistencyJSON } from "./consistency"; + +const studies = JSON.parse( + readFileSync("fixtures/consistency/studies.json", "utf8"), +); + +function subset(actual: unknown, expected: unknown): void { + if (typeof expected === "number") expect(actual).toBeCloseTo(expected, 12); + else if (Array.isArray(expected)) { + expect(Array.isArray(actual)).toBe(true); + expected.forEach((value, i) => subset((actual as unknown[])[i], value)); + } else if (expected !== null && typeof expected === "object") { + for (const [key, value] of Object.entries(expected)) + subset((actual as Record)[key], value); + } else expect(actual).toEqual(expected); +} + +describe("shared Python/TypeScript study fixtures", () => { + for (const study of studies) + it(study.name, () => { + const report = analyzeConsistencyJSON(JSON.stringify(study.input)); + subset(report, study.expected); + const reversed = { + ...study.input, + ratings: [...study.input.ratings].reverse(), + }; + expect(analyzeConsistencyJSON(JSON.stringify(reversed))).toEqual(report); + }); +}); diff --git a/js/consistency-io.test.ts b/js/consistency-io.test.ts new file mode 100644 index 0000000..1c32b5a --- /dev/null +++ b/js/consistency-io.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeConsistency, + analyzeConsistencyJSON, + inputToJSON, + reportToJSON, + consistencyScores, + renderConsistencyMarkdown, +} from "./consistency"; + +const plan = { + score_name: "judge", + raters: ["a", "b"], + categories: ["no", "yes"], +}; +const ratings = [ + { case_id: "x", rater_id: "a", category: "yes" }, + { case_id: "x", rater_id: "b", category: "no" }, +]; + +describe("portable consistency evidence", () => { + it("renders Markdown image syntax and backslashes literally", () => { + const report = analyzeConsistency([], { + plan: { + ...plan, + score_name: "![external](https://example.com/track)\\*", + }, + }); + expect(renderConsistencyMarkdown(report)).toContain( + "\\!\\[external\\]\\(https://example\\.com/track\\)\\\\\\*", + ); + }); + it("round trips selected data and recomputes its report", () => { + const references = new Map([["x", "yes"]]); + const report = analyzeConsistency(ratings, { plan, references }); + expect( + analyzeConsistencyJSON(inputToJSON(ratings, { plan, references })), + ).toEqual(report); + expect(JSON.parse(reportToJSON(report))).toEqual(report); + }); + it.each([ + '{"version":1,"version":1}', + '{"x":NaN}', + '{"x":1e999}', + '{"x":{"a":1,"\\u0061":2}}', + '{"x":"\\ud800"}', + ])("rejects invalid JSON evidence: %s", (content) => { + expect(() => analyzeConsistencyJSON(content)).toThrow(); + }); + it("rejects unknown fields and excessive structure", () => { + const data = JSON.parse(inputToJSON(ratings, { plan })); + data.plan.unknown = 1; + expect(() => analyzeConsistencyJSON(JSON.stringify(data))).toThrow( + /unknown/, + ); + expect(() => + analyzeConsistencyJSON(inputToJSON(ratings, { plan }), { + max_json_nodes: 2, + }), + ).toThrow(/structure/); + }); + it("keeps prototype-looking identifiers as ordinary data", () => { + const unusual = { ...plan, cases: ["__proto__"] }; + const references = new Map([["__proto__", "yes"]]); + const content = inputToJSON([], { plan: unusual, references }); + expect( + analyzeConsistencyJSON(content).groups[0].consensus.reference + .labeled_cases, + ).toBe(1); + }); + it("projects bounded scores and leaves absent accuracy undefined", () => { + const report = analyzeConsistency(ratings, { plan }); + const scores = consistencyScores(report); + expect(scores.map((score) => score.score)).toEqual([0, 1, null]); + expect(scores.every((score) => !score.metadata?.report)).toBe(true); + const included = consistencyScores(report, { includeReport: true }); + report.score_name = "changed"; + expect((included[0].metadata?.report as typeof report).score_name).toBe( + "judge", + ); + }); + it("escapes labels in human reports", () => { + const report = analyzeConsistency([], { + plan: { ...plan, score_name: "judge|