#!/usr/bin/env python3 """Axis 6 (temporal localization and trend): LLM extraction with deterministic computation. The LLM reads each candidate timeline and returns its numeric series. classify() computes the trend shape from the series; extremum questions take the highest or lowest reading. Golds are formatted from PROSE_TEMPLATE_* with the computed operands. synthesize() authors synthetic questions. python -m pipeline.questions.axis6 discovery -> _discover_axis6.json """ from __future__ import annotations import json import random from datetime import date, timedelta from pathlib import Path from pipeline.common import BASE, FIELDS, Progress, model, patient_context, patient_id, patient_line from pipeline.llm import ChatClient PATIENT = patient_id() ENGINE = model("generation") AXIS = 6 SHAPE_VOCAB = ["steadily increased", "steadily decreased", "increased then decreased", "decreased then increased", "remained essentially unchanged"] PROSE_TEMPLATE_EXTREMUM_Q = ("Across the whole record, what was this patient's {extremum_kind} charted " "{series_entity} value, and when was it measured?") PROSE_TEMPLATE_EXTREMUM_A = ("The {extremum_kind} charted {series_entity} was {extremum_num:g}{unit_sp} " "on {extremum_date}.") PROSE_TEMPLATE_MC_Q = ("Between {range_from} and {range_to}, how did this patient's {series_entity} " "change over the readings taken in that period?") PROSE_TEMPLATE_MC_A = "Over {range_from} to {range_to}, {series_entity} {correct_option_text}." def sign(x): return 1 if x > 0 else (-1 if x < 0 else 0) def classify(ns): """Trend shape of a series, computed deterministically; None rejects the series.""" lo, hi, med = min(ns), max(ns), sorted(ns)[len(ns) // 2] if med != 0 and (hi - lo) / abs(med) <= 0.05: return "remained essentially unchanged" d = [b - a for a, b in zip(ns, ns[1:])] nz = [sign(x) for x in d if x != 0] if not nz: return "remained essentially unchanged" flips = sum(1 for a, b in zip(nz, nz[1:]) if a != b) if flips == 0: return "steadily increased" if nz[0] > 0 else "steadily decreased" if flips == 1: return "increased then decreased" if nz[0] > 0 else "decreased then increased" return None def best_window(points): nums = [p["num"] for p in points] for length in range(len(nums), 2, -1): for i in range(0, len(nums) - length + 1): sh = classify(nums[i:i + length]) if sh is not None: return (i, i + length, sh) return None def mid(d1, d2): a, b = date.fromisoformat(d1), date.fromisoformat(d2) return (a + (b - a) / 2).isoformat() EXTRACT_SYS = ( "You read one entity's timeline from a synthetic longitudinal medical chart and extract its\n" "numeric readings. Charts are terse, and their format varies across patients and departments.\n" "Return only JSON.") EXTRACT_SCHEMA = """Return JSON in this form: {"is_numeric_series": , "measurand": "", "unit": "", "points": [{"date": "YYYY-MM-DD", "visit": , "num": , "raw": ""}], "aliases": [""], "reject_reason": ""}""" def extract_series(cli, t): """LLM extraction of the numeric readings of one timeline. Trailing parentheticals are dropped; dates, censored values ("< 6") and readings that mix two measurands are rejected. """ segs = [{"value": s.get("value"), "date": (s.get("valid_from") or {}).get("date"), "visit": (s.get("valid_from") or {}).get("visit_number")} for s in t.get("segments") or []] entity, department, aspect = t.get("entity"), t.get("department"), t.get("aspect") segment_lines = "\n".join(f"{s['date']} v{s['visit']}: {s['value']!r}" for s in segs) usr = ( f"Entity: {entity}\nDepartment: {department}\nAspect: {aspect}\n\n" "Segments, each one value and the date it was first recorded:\n" + segment_lines + "\n\n" "Extract the numeric readings of one measurand.\n\n" "Reject the series (is_numeric_series false) if any of these holds:\n" "- The values are not numeric measurements but impressions, findings or narrative.\n" "- More than one measurand is charted here, for example '/ /', which\n" " is ambiguous.\n" "- The values are censored, such as '< 6' or '> 60', and cannot be ordered.\n" "- Fewer than 3 readings carry a real number on distinct dates.\n" "- A number is a date or a lab accession number, not a reading.\n\n" "Take the number that is the reading, and ignore trailing text in parentheses, lab codes, embedded\n" "dates, reference ranges and targets such as 'goal LDL < 100'. Read a value in scientific notation,\n" "such as '1.2x10^3', as a number. Give one reading per date, and use the segment's own date and\n" "visit.\n\n" + EXTRACT_SCHEMA) r = cli.complete_json(EXTRACT_SYS, usr) if not r.get("is_numeric_series"): return None, r.get("reject_reason", "not a numeric series") pts = [p for p in (r.get("points") or []) if isinstance(p.get("num"), (int, float)) and p.get("date")] seen, uniq = set(), [] for p in sorted(pts, key=lambda x: x["date"]): if p["date"] in seen: continue seen.add(p["date"]) uniq.append(p) if len(uniq) < 3: return None, f"only {len(uniq)} usable readings" return {"entity": r.get("measurand") or t.get("entity"), "department": t.get("department"), "unit": (r.get("unit") or "").strip(), "points": uniq, "aliases": [a.lower() for a in (r.get("aliases") or [])]}, None UNIQ_SYS = ("You check whether a claimed extreme value is unique in a patient's chart. Return only JSON.") LINES_PER_CHECK = 70 def extremum_is_unique(cli, s, kind, ex_num, ex_date, hay_rows): """Whether the extreme reading of a series is unique, checked against every chart line naming the measurand, LINES_PER_CHECK lines per call; the first group that finds a higher (or lower) value or an equal value on another date decides.""" names = ", ".join(sorted({s["entity"], *s.get("aliases", [])})) rel = [f" {d} v{v}: {tx}" for v, d, tx in hay_rows if any(n and n.lower() in tx.lower() for n in {s["entity"], *s.get("aliases", [])})] for i in range(0, len(rel), LINES_PER_CHECK): unique, violations = _unique_in(cli, names, kind, ex_num, ex_date, rel[i:i + LINES_PER_CHECK]) if not unique: return False, violations return True, [] def _unique_in(cli, names, kind, ex_num, ex_date, rel): """One uniqueness call over a group of chart lines.""" chart_lines = "\n".join(rel) beyond = "higher" if kind == "highest" else "lower" r = cli.complete_json( UNIQ_SYS, f"Measurand: {names}\nClaim: the {kind} charted value is {ex_num:g} on {ex_date}.\n\n" "Chart lines that mention it:\n" + chart_lines + "\n\n" "Each line starts with the date of the visit that recorded it. Ignore reference ranges and targets.\n" "Is the claim true, and is that date unique?\n" f"- A line showing a {beyond} value anywhere makes the claim false.\n" "- A separate measurement with an equal value on another date makes it not unique. A reading\n" " copied forward into a later visit, with the same value and its original test date or no date\n" " of its own, is the same reading and is fine, because charts copy readings forward.\n\n" "Return JSON in this form:\n" '{"unique": ,\n' ' "violations": [{"date": "...", "value": , "why": "beats|ties", "line": "..."}]}') return bool(r.get("unique")), r.get("violations") or [] def discover() -> None: """Discover the axis-6 candidates of this patient -> _discover_axis6.json.""" cli = ChatClient(model=ENGINE, reasoning_effort="medium", max_completion_tokens=4000) ts = [json.loads(l) for l in open(f"{BASE}/evidence_chain.jsonl") if l.strip()] chain = [json.loads(l) for l in open(f"{BASE}/event_chain.jsonl") if l.strip()] LAST_V = max(r["visit_number"] for r in chain) hay_rows = [(r["visit_number"], r["date"], str(a)) for r in chain for f in FIELDS + ["icd_notes"] for a in (r.get(f) or [])] hay_rows += [(r["visit_number"], r["date"], f"{k}={val}") for r in chain for k, val in (r.get("vitals") or {}).items()] cand = [t for t in ts if len(t.get("segments") or []) >= 3] series, rejects = [], [] with Progress("axis6 extract (LLM)", total=len(cand), out=f"{BASE}/progress_axis6_discover.json") as pg: for t in cand: try: s, why = extract_series(cli, t) except Exception as e: s, why = None, f"error: {str(e)[:80]}" if s: series.append(s) else: rejects.append((t.get("entity"), why)) pg.tick(ok=bool(s)) candidates = [] for s in series: pts, nums = s["points"], [p["num"] for p in s["points"]] ent, unit_sp = s["entity"], (f" {s['unit']}" if s["unit"] else "") evidence = [{"visit": p.get("visit"), "date": p["date"], "field": "evidence_chain", "atom": p.get("raw")} for p in pts] for kind in ("highest", "lowest"): ex = max(nums) if kind == "highest" else min(nums) if nums.count(ex) != 1: continue k = nums.index(ex) ex_date, ex_visit = pts[k]["date"], pts[k].get("visit") try: ok, _ = extremum_is_unique(cli, s, kind, ex, ex_date, hay_rows) except Exception: ok = False if not ok: continue ops = {"extremum_kind": kind, "series_entity": ent, "extremum_num": ex, "unit_sp": unit_sp, "extremum_date": ex_date, "n_points": len(pts), "series_department": s["department"], "unit": s["unit"]} answer = PROSE_TEMPLATE_EXTREMUM_A.format(**ops) candidates.append({ "qid": f"{PATIENT}/axis{AXIS}/{len(candidates):03d}", "patient_id": PATIENT, "axis": f"{AXIS}_{kind}_value_and_date", "question": PROSE_TEMPLATE_EXTREMUM_Q.format(**ops), "answer": answer, "answer_type": "open_exact", "grounding": "record", "evidence_visits": [p.get("visit") for p in pts], "gt_visit": ex_visit, "gt_date": ex_date, "question_appearance_window": {"from": ex_date, "to": max(r["date"] for r in chain)}, "derivation": {"evidence": evidence, "steps": [f"{ent} has {len(pts)} readings: {[(p['date'], p['num']) for p in pts]}.", f"{kind} = {ex:g}{unit_sp}, at {ex_date}.", f"Uniqueness confirmed against every chart line naming {ent}."], "gold": answer, "rivals": [], "operands": ops, "derived": True}, "provenance": f"LLM-extracted series ({ENGINE}); {kind} unique across the chart", "axis_specific": {"template": f"{kind}_value_and_date", "n_points": len(pts)}, }) w = best_window(pts) if w: i, j, shape = w wp = pts[i:j] rf = mid(pts[i - 1]["date"], wp[0]["date"]) if i > 0 else \ (date.fromisoformat(wp[0]["date"]) - timedelta(days=15)).isoformat() rt = mid(wp[-1]["date"], pts[j]["date"]) if j < len(pts) else \ (date.fromisoformat(wp[-1]["date"]) + timedelta(days=15)).isoformat() rnd = random.Random(f"{PATIENT}|{ent}|trend") opts = SHAPE_VOCAB[:] rnd.shuffle(opts) opts = opts[:4] if shape not in opts: opts[rnd.randrange(4)] = shape ops = {"range_from": rf, "range_to": rt, "series_entity": ent, "correct_option_text": shape, "shape_class": shape, "series_department": s["department"], "n_window": len(wp), "n_points": len(pts)} answer = PROSE_TEMPLATE_MC_A.format(**ops) nums_w = [p["num"] for p in wp] candidates.append({ "qid": f"{PATIENT}/axis{AXIS}/{len(candidates):03d}", "patient_id": PATIENT, "axis": f"{AXIS}_trend_direction_mc", "question": PROSE_TEMPLATE_MC_Q.format(**ops), "answer": answer, "answer_type": "multiple_choice", "grounding": "record", "options": [{"label": "ABCD"[k], "content": o, "is_correct": o == shape} for k, o in enumerate(opts)], "evidence_visits": [p.get("visit") for p in wp], "gt_visit": wp[-1].get("visit"), "gt_date": wp[-1]["date"], "question_appearance_window": {"from": rt, "to": max(r["date"] for r in chain)}, "derivation": {"evidence": evidence[i:j], "steps": [f"{ent} in {rf}..{rt}: {nums_w}.", f"Deltas: {[round(b-a,2) for a,b in zip(nums_w, nums_w[1:])]}.", f"classify() -> '{shape}'."], "gold": answer, "rivals": [], "operands": ops, "derived": True}, "provenance": f"LLM-extracted series ({ENGINE}); shape COMPUTED by classify()", "axis_specific": {"template": "trend_direction_mc", "shape_class": shape}, }) out = {"axis": AXIS, "patient_id": PATIENT, "n_record": len(candidates), "candidates": candidates} Path(f"{BASE}/_discover_axis{AXIS}.json").write_text(json.dumps(out, ensure_ascii=False, indent=1)) print(f"\npatient {PATIENT} LAST_V={LAST_V} engine={ENGINE}") print(f" timelines with >=3 segments : {len(cand)}") print(f" LLM-extracted numeric series: {len(series)}") print(f" rejected : {len(rejects)}") for e, why in rejects[:5]: print(f" {str(e)[:38]:<38} {str(why)[:56]}") print(f" CANDIDATES: {len(candidates)} -> {BASE}/_discover_axis{AXIS}.json") def synthesize(cli, chain, by_v, forbidden, n, batch, existing_series): """Author `n` numeric series whose values follow a fixed profile for the intended shape; one LLM call picks the measurands and baselines.""" out, vs = [], sorted(by_v) SHAPES = ["increased then decreased", "decreased then increased", "steadily increased", "steadily decreased"] FRAC = {"increased then decreased": [0.2, 0.5, 0.85, 0.5, 0.25], "decreased then increased": [0.85, 0.5, 0.15, 0.5, 0.8], "steadily increased": [0.15, 0.4, 0.65, 0.9], "steadily decreased": [0.9, 0.65, 0.4, 0.15]} patient, context = patient_line(), patient_context(chain) tracked_list = "\n".join(f"- {e}" for e in existing_series[:50]) forbidden_inline = ", ".join(forbidden[:25]) r = cli.complete_json( "You choose recurring numeric lab tests for a trend question of a medical-memory benchmark.\n" "Return only JSON.", f"{patient}\n{context}\n\n" f"Choose {n} different recurring numeric lab tests that this patient's care might track over\n" "years. Give a plausible baseline value and unit for each; the trajectory is computed from it.\n\n" "Avoid lab tests that the chart already tracks, because a shared word or abbreviation makes both\n" "unusable:\n" + tracked_list + "\nAlso avoid these, which answer keys already track: " + forbidden_inline + "\n\n" "Return JSON in this form:\n" '{"labs": [{"label": "", "unit": "", "baseline": ,\n' ' "decimals": <0|1|2>, "entity": "", "topic": "",\n' ' "department": ""}]}') labs = r.get("labs") or [] if not isinstance(labs, list): print(" axis6: the reply's labs are not a list") labs = [] depts = {x["department"] for x in chain} for i, lab in enumerate(labs[:n]): if not (isinstance(lab, dict) and all(isinstance(lab.get(k), str) and lab[k].strip() for k in ("label", "entity"))): print(f" axis6[{i}] rejected: the lab lacks a label or an entity") continue try: base_v, dec = float(lab["baseline"]), int(lab.get("decimals", 1)) except (KeyError, TypeError, ValueError): print(f" axis6[{i}] rejected: the lab lacks a numeric baseline") continue shape = SHAPES[i % len(SHAPES)] frac = FRAC[shape] dept = next((d for d in depts if lab.get("department", "") and lab["department"] in d), by_v[vs[i % len(vs)]]["department"]) hosts = [v for v in vs if by_v[v]["department"] == dept][:len(frac)] if len(hosts) < 3: hosts = vs[i::max(1, len(vs) // len(frac))][:len(frac)] if len(hosts) < 3: continue unit = str(lab.get("unit") or "").strip() pts, atoms = [], [] for k, v in enumerate(hosts): val = round(base_v * (0.5 + frac[k]), dec) txt = f"{lab['label']} {val:g} {unit}".strip() pts.append((v, by_v[v]["date"], val)) atoms.append({"atom_id": f"{batch}/{i:02d}_{k:02d}", "batch_id": batch, "axis": "6", "visit_number": v, "date": by_v[v]["date"], "department": by_v[v]["department"], "field": "tests_and_medication", "text": txt, "entities": [lab["entity"]], "topic": lab.get("topic"), "anchors_axis": "6", "anchors_on": []}) nums = [p[2] for p in pts] gold = f"Over {pts[0][1]} to {pts[-1][1]}, {lab['entity']} {shape}." out.append({"atom": atoms[0], "atoms": atoms, "q": { "qid": f"{PATIENT}/axis6/S{i:03d}", "patient_id": PATIENT, "axis": "6_trend_direction", "question": (f"Between {pts[0][1]} and {pts[-1][1]}, how did this patient's {lab['entity']} " f"change over the readings taken in that period?"), "answer": gold, "answer_type": "open_exact", "grounding": f"synthetic:{batch}", "evidence_visits": [p[0] for p in pts], "gt_visit": pts[-1][0], "gt_date": pts[-1][1], "question_appearance_window": {"from": pts[-1][1], "to": by_v[vs[-1]]["date"]}, "derivation": {"evidence": [{"visit": p[0], "date": p[1], "field": "tests_and_medication", "atom": f"{lab['label']} {p[2]:g} {unit}".strip()} for p in pts], "steps": [f"{lab['entity']} measured {len(pts)} times: {nums}.", f"Deltas: {[round(b-a,2) for a,b in zip(nums,nums[1:])]}.", f"Therefore the trajectory {shape}."], "gold": gold, "rivals": [], "operands": {"series_entity": lab["entity"], "series_department": dept, "unit": unit, "points": [{"date": p[1], "num": p[2]} for p in pts], "n_points": len(pts), "shape_class": shape}, "derived": True}, "provenance": f"synthetic trend ({batch}); value sequence computed so the shape is exact " f"(absolute values immaterial for a memory test)", "axis_specific": {"shape_class": shape, "depends_on_atoms": [x["atom_id"] for x in atoms]}, }}) return out if __name__ == "__main__": discover()