diff --git a/ROADMAP.md b/ROADMAP.md index fb50e10..64e614c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,7 +47,7 @@ feature count. - ⏳ Each fixed bug / behavioural failure becomes a regression fixture (ongoing convention). ## v1.0.0 — Stable public release ✅ (released) -- Uniform engine envelope contract (all 9 engines) + JSON schemas + STABILITY.md (SemVer + deprecation policy). +- Uniform engine envelope contract (all 8 engines) + JSON schemas + STABILITY.md (SemVer + deprecation policy). - Coverage measured honestly (subprocess) + gated; two CI jobs (matrix + R). - `epistemic_grade` conformed to the envelope contract. diff --git a/STATUS.md b/STATUS.md index 59f8495..288d6ae 100644 --- a/STATUS.md +++ b/STATUS.md @@ -66,7 +66,7 @@ where R / the package is absent (e.g. on the default CI runner), and run locally | Bash-capable components routed through the parser (`statistician`, `power-sample-size`) | done ✅ — they call the parser CLI | | offline read-only agents routed through the parser | not applicable — no `Bash`; they read `profile.md` directly by design | | `schemas/` (JSON Schema I/O contracts: envelope, finding, power request/response, profile) | implemented ✅, enforced in `tests/test_schemas.py` | -| uniform engine envelope contract (every engine → `ok`/`error` + graded `finding`) | implemented ✅, verified for all 9 engines in `tests/test_envelope_contract.py` (v1.0.0 stable contract) | +| uniform engine envelope contract (every engine → `ok`/`error` + graded `finding`) | implemented ✅, verified for all 8 engines in `tests/test_envelope_contract.py` (v1.0.0 stable contract) | | per-engine bespoke response schemas (each engine's `data` payload pinned, not just the shared envelope) | implemented ✅, enforced in `tests/test_engine_schemas.py` (representative fixture + negative control per engine; v1.2.0) | ## Behavioral validation (prompt layer) diff --git a/schemas/power_response.schema.json b/schemas/power_response.schema.json index 4ccaf10..1f41464 100644 --- a/schemas/power_response.schema.json +++ b/schemas/power_response.schema.json @@ -14,12 +14,14 @@ "properties": { "n_per_group": {"type": "integer", "minimum": 1}, "n_total": {"type": "integer", "minimum": 1}, + "n_group1": {"type": "integer", "minimum": 1}, + "n_group2": {"type": "integer", "minimum": 1}, "events_required": {"type": "integer", "minimum": 1}, "method": {"type": "string", "description": "Exact analytic routine used."}, "assumptions": {"type": "object", "description": "alpha, power, alternative, and the effect input echoed back."}, "finding": {"$ref": "finding.schema.json"} }, - "description": "Sample-size designs carry {n_per_group, n_total}; survival carries {events_required}." + "description": "Sample-size designs carry {n_per_group, n_total}; two_sample_t also carries per-arm {n_group1, n_group2} (differ when ratio != 1); survival carries {events_required}." } }, "allOf": [ diff --git a/scripts/behavioral/judge_behavior.py b/scripts/behavioral/judge_behavior.py index 7d4da11..2a190b0 100644 --- a/scripts/behavioral/judge_behavior.py +++ b/scripts/behavioral/judge_behavior.py @@ -35,30 +35,22 @@ def build_judge_prompt(case, agent_response): ) +_DECODER = json.JSONDecoder() + + def _extract_json_object(text): - """Return the first balanced top-level {...} substring, or None. String-aware so braces - inside quoted values do not unbalance the scan.""" + """Return the first `{...}` substring that decodes as valid JSON, or None. + + json.JSONDecoder.raw_decode does the string-aware brace matching for us and, unlike a + plain balance scan, only accepts a `{` that begins genuinely valid JSON. + """ start = text.find("{") while start != -1: - depth, in_str, esc = 0, False, False - for i in range(start, len(text)): - ch = text[i] - if in_str: - if esc: - esc = False - elif ch == "\\": - esc = True - elif ch == '"': - in_str = False - elif ch == '"': - in_str = True - elif ch == "{": - depth += 1 - elif ch == "}": - depth -= 1 - if depth == 0: - return text[start:i + 1] - start = text.find("{", start + 1) + try: + _obj, end = _DECODER.raw_decode(text, start) + return text[start:end] + except json.JSONDecodeError: + start = text.find("{", start + 1) return None diff --git a/scripts/core/citation_parse.py b/scripts/core/citation_parse.py index 110c83a..d312e9d 100644 --- a/scripts/core/citation_parse.py +++ b/scripts/core/citation_parse.py @@ -16,7 +16,25 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from scripts.lib import json_io, provenance, epistemic # noqa: E402 -MARKER = re.compile(r"\[(\d+)\]") +# A numeric marker group: starts with a digit, then only digits, commas, spaces, and +# hyphen/en-dash — so [1], [1,2,5], and [1-3]/[1–3] match, but [see 2] does not. +MARKER = re.compile(r"\[(\d[\d\s,–-]*)\]") +_RANGE = re.compile(r"(\d+)\s*[–-]\s*(\d+)") + + +def _expand(inner): + """Expand one marker group's inner text into the set of integers it cites.""" + out = set() + for part in inner.split(","): + part = part.strip() + r = _RANGE.fullmatch(part) + if r: + lo, hi = int(r.group(1)), int(r.group(2)) + if lo <= hi: + out.update(range(lo, hi + 1)) + elif part.isdigit(): + out.add(int(part)) + return out def parse(req): @@ -24,8 +42,8 @@ def parse(req): refs = req["references"] n_refs = len(refs) - markers = sorted({int(m) for m in MARKER.findall(body)}) - cited = set(markers) + cited = set().union(*(_expand(g) for g in MARKER.findall(body))) if MARKER.search(body) else set() + markers = sorted(cited) orphan = [i for i in range(1, n_refs + 1) if i not in cited] dangling = [m for m in markers if m < 1 or m > n_refs] diff --git a/scripts/core/power_sample_size.py b/scripts/core/power_sample_size.py index b385ae6..c75e431 100644 --- a/scripts/core/power_sample_size.py +++ b/scripts/core/power_sample_size.py @@ -139,14 +139,17 @@ def compute(req): alpha = float(req.get("alpha", 0.05)) power = float(req.get("power", 0.80)) base = {"alpha": alpha, "power": power, "alternative": "two-sided"} + extra = {} # op-specific output fields merged into the result if test == "two_sample_t": effect_size = float(req["effect_size"]) ratio = float(req.get("ratio", 1.0)) n_per_group = two_sample_t(effect_size, alpha, power, ratio) - n_total = n_per_group + int(math.ceil(n_per_group * ratio)) + n_group2 = int(math.ceil(n_per_group * ratio)) # group 2 = ratio × group 1 + n_total = n_per_group + n_group2 method = "statsmodels.stats.power.TTestIndPower" assumptions = {**base, "effect_size_d": effect_size, "ratio": ratio} + extra = {"n_group1": n_per_group, "n_group2": n_group2} claim = f"n={n_per_group}/group for d={effect_size}, alpha={alpha}, power={power}" elif test == "paired_t": effect_size = float(req["effect_size"]) @@ -220,7 +223,7 @@ def compute(req): source=provenance.engine_trace("power_sample_size", run_id=rid, anchor="result"), source_independence=1, ) - return {"n_per_group": n_per_group, "n_total": n_total, + return {"n_per_group": n_per_group, "n_total": n_total, **extra, "method": method, "assumptions": assumptions, "finding": finding} diff --git a/tests/core/test_citation_parse.py b/tests/core/test_citation_parse.py index 266f241..ad277a9 100644 --- a/tests/core/test_citation_parse.py +++ b/tests/core/test_citation_parse.py @@ -35,3 +35,15 @@ def test_clean_citations_no_issues(): out = run_engine(payload) assert out["data"]["orphan_references"] == [] assert out["data"]["dangling_citations"] == [] + + +def test_expands_ranges_and_lists(): + # [1-3] is a range, [2,4] a list — both must expand to individual cited markers. + payload = { + "body_text": "Range [1-3] and list [2,4]. Also [6].", + "references": ["r1", "r2", "r3", "r4", "r5"], + } + out = run_engine(payload) + assert out["data"]["cited_markers"] == [1, 2, 3, 4, 6] + assert out["data"]["orphan_references"] == [5] # only ref 5 uncited + assert out["data"]["dangling_citations"] == [6] # [6] exceeds 5 references diff --git a/tests/core/test_power_sample_size.py b/tests/core/test_power_sample_size.py index ca00337..5601f07 100644 --- a/tests/core/test_power_sample_size.py +++ b/tests/core/test_power_sample_size.py @@ -1,4 +1,5 @@ import json +import math import subprocess import sys from pathlib import Path @@ -86,6 +87,15 @@ def test_engine_deterministic_for_same_input(): assert a == b +def test_two_sample_t_surfaces_both_group_ns_when_unequal(): + # ratio=2 => group 2 is twice group 1; both counts must be explicit, not folded into n_total. + out = run_engine({"test": "two_sample_t", "effect_size": 0.5, "ratio": 2.0}) + assert out["status"] == "ok" + d = out["data"] + assert d["n_group2"] == int(math.ceil(d["n_group1"] * 2.0)) + assert d["n_total"] == d["n_group1"] + d["n_group2"] + + def test_one_sample_t_family(): out = run_engine({"test": "one_sample_t", "effect_size": 0.5, "alpha": 0.05, "power": 0.80}) assert out["status"] == "ok" diff --git a/tests/test_envelope_contract.py b/tests/test_envelope_contract.py index a50c7d4..cac00b1 100644 --- a/tests/test_envelope_contract.py +++ b/tests/test_envelope_contract.py @@ -1,5 +1,5 @@ """The uniform engine contract (v1.0.0): EVERY L0 engine returns an `ok` envelope whose `data` -carries a valid graded `finding`. This validates a representative output from each of the nine +carries a valid graded `finding`. This validates a representative output from each of the eight engines against schemas/envelope.schema.json — proving one stable contract across the core. """ import json