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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion schemas/power_response.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
34 changes: 13 additions & 21 deletions scripts/behavioral/judge_behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
24 changes: 21 additions & 3 deletions scripts/core/citation_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,34 @@
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):
body = req["body_text"]
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]

Expand Down
7 changes: 5 additions & 2 deletions scripts/core/power_sample_size.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down Expand Up @@ -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}


Expand Down
12 changes: 12 additions & 0 deletions tests/core/test_citation_parse.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions tests/core/test_power_sample_size.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import json
import math
import subprocess
import sys
from pathlib import Path
Expand Down Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion tests/test_envelope_contract.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading