pystrider — deep dive¶
This is the full technical tour. For the friendly front door — bring your rules, bridge them, brew a working UI — see the README. Everything below is what powers that, in detail.
A hypothesis-driven code analyzer, bug-fixer, and policy-conformance checker built on the Universal Graph Machine library. Instead of matching static bug patterns, it reasons about a Python function the way a person does: suppose a value for a parameter, symbolically run the code by applying an operational semantics expressed as UGM rules, and read what happens — with a human-readable trace behind every conclusion. Then it repairs the code and verifies the fix by re-running the analysis.
The unusual part: every conclusion — every bug, every fix, every policy-violation — is a replayable proof object, and the same small rule engine runs in several directions on it: reading code (analysis), fixing it (repair), explaining a crash (diagnosis), and checking it against a business policy (conformance). Nothing is trusted because a tool claimed it — everything is checked by re-running the reasoning.
The reasoning also drives a full generation loop — the playground composes a
knowledge base into a runnable, verified application (reason → compose → emit → drive), where the
soundness of composition is itself CNL: a second in-repo package (grammapy) supplies a
composition algebra whose every check is a rule-module over the same graph, and the emitted app is
trusted because pystrider drives it (a real Textual app, observed to behave). Change one sentence of
the spec and the code re-derives with a proof of what changed and why — the "policy change →
verified code change" artifact no LLM regeneration can produce.
Analysis + repair are productized; diagnosis and conformance are proven end-to-end as probes. A
library's API surface is now absorbed as data (pystrider.absorb, from live type hints), and the same
anomaly-checking loop turns on rule banks themselves (rulestrider) — the KB-ingestion gate that makes
authored knowledge trustworthy.
pystrider owns no engine code. Intake materializes graph structure from ast; everything downstream
— the analysis semantics, the four composition combinators, and cross-cutting constraint resolution —
reasons through the public UGM firmware (suppose / ask_goal / choose) as CNL rules. Python is
confined to its proper role: the tool boundaries (intake, absorb), AST emission, and execution.
What it does (today)¶
The vertical loop is proven and productized across four analysis/repair slices. A third axis — crash → root cause (diagnosis) is proven as a probe: the loop run backwards over the hypothesis space, abducing the input that reproduces an observed exception, then handing the cause to the repair axis. A fourth axis — code ⟷ policy conformance is proven as a probe: a business policy and the code in one graph, joined by a declarative bridge across their vocabularies, with spec-vs-code divergence derived as a fact and repaired spec-directed. And the reasoning drives a generation loop (the playground) — a knowledge base composed into a runnable app, verified by driving it:
- Slice A — correct value flow. Value lives in a per-
(program-point, variable)cell lattice, so reassignment (y = a; y = b), branch-merge (union of both arms), bounded loop unrolling, and path-sensitive refinement (aif v is not None:fork narrowsvper branch, so the deref on the safe branch is not a false positive) are all correct. Every value union at a join is a rule derivation (the frame rule firing once per edge), never a Python meet. - Slice B — a Session. Several functions coexist in one shared graph (identity by
(function, source_name)), each analyzed under its ownfocus_scope, detection read-only so functions/hypotheses never contaminate one another — and a value flows across a call boundary into the callee. - Slice C — a second effect. Beyond None-derefs (
AttributeError), areturns_noneoutcome ("returns None when a non-None was intended") is authored as one more semantics rule + two library operators — reusing the whole retrieve / verify / CHOOSE loop with no new machinery. - Means-ends repair. For any effect: retrieve applicable edit operators from an effect-keyed library by backward-CHAIN, materialize each as real Python (AST rewrite), verify each by re-intaking + re-analyzing the edited source, and CHOOSE the graded-best.
- Whole-function auto-fix.
repair_alldrives repair to a fixpoint — fix every outcome (of any effect), each edit verified to make progress and introduce no regression, until the function is clean — returning the edited source plus an audit log of what it changed and why. - Honest "clean" — no building on silence. Intake emits a visible
not_modelledmarker for any statement kind it can't thread (aug-assign, attribute store, tuple unpack, bare call, …) instead of silently framing a stale value forward;caveats()surfaces them andrepair_allqualifies its verdict — "repaired to clean (modulo N unmodelled statements)",fully_modelled=False— so "clean" means "checked and clear", never "nothing derived".
A small, nontrivial example¶
from pystrider import intake_function, analyze, choose_repair
src = """
def pipeline(raw):
data = validate(raw) # data is now the validated (non-None) result
data = raw # ...but a stray second assignment clobbers it with the raw input
return data.rows() # so on a None input this dereferences None
"""
ik = intake_function(src)
# SUPPOSE the input is None, then symbolically RUN the semantics (no pattern matching):
[outcome] = analyze(ik, {"raw": "none"})
print(outcome.headline())
# assuming raw=None: data.rows (line 5) -> AttributeError
# every conclusion carries its real UGM provenance trace (abbreviated):
print("\n".join(outcome.trace))
# attr9 raises attribute_error <- rule.?e.raises.attribute_error
# e8 eval_to none <- rule.?e.eval_to (data is None at the deref's program point)
# c_p2_data has_value none <- rule.?c2.has_value (the LAST write — data = raw — wins)
# ...
# retrieve + materialize + verify + CHOOSE a repair (smallest / most-local wins):
sel = choose_repair(ik, {"raw": "none"}, outcome)
print(sel.winner.v2_source)
# def pipeline(raw):
# data = validate(raw)
# data = raw
# if data is not None:
# return data.rows()
This is exactly the case a "one variable, one value" (SSA) model or a pattern matcher gets wrong:
data is assigned twice, and only the last write (data = raw) reaches the deref. pystrider
threads value through a per-(program-point, variable) cell lattice, so it reads the value that is
actually live at the return — and if you swap the two assignments (data = raw first), the
outcome soundly disappears (no false positive).
For the multi-function / inter-procedural version, see demos/03_session_interprocedural.py.
Fixing, not just finding¶
The point of running the semantics is that it also tells you how to repair the code — and
repair_all drives that to a fixpoint, fixing every outcome (across all effects) one verified
edit at a time:
from pystrider import intake_function, repair_all
buggy = """
def process(cfg, data):
conn = cfg
a = conn.open() # AttributeError if cfg is None
rows = data
return rows # returns None if data is None (a different bug, different effect)
"""
plan = repair_all(intake_function(buggy), {"cfg": "none", "data": "none"})
print("\n".join(plan.summary()))
# 2 edit(s) -> repaired to clean
# 1. fix 'conn.open' (attribute_error, line 4) via guard_base [fit 1.00] -> 1 left
# 2. fix 'return rows' (returns_none, line 6) via coalesce_or [fit 1.00] -> 0 left
print(plan.source) # the edited, now-clean Python you can apply
# def process(cfg, data):
# conn = cfg
# if conn is not None:
# a = conn.open()
# rows = data
# return rows or {}
Each candidate edit is materialized as real Python (an AST rewrite) and accepted only if
re-analyzing the edited source shows it removed the target and introduced no new problem
(regression-checking) — the graded-best survivor is applied, then the loop re-analyzes and repeats
until the function is clean (or reports an honest stuck). So the fix is trusted because the code
re-runs clean, not because an operator claimed it would. plan.source is source you can apply;
plan.steps is the audit log of what changed and why. (For a single site with the full CHOOSE
trace, use choose_repair, above.)
A third axis: crash → root cause (diagnosis)¶
Analysis reads code; diagnosis explains a crash. Analysis runs the loop
forward — you SUPPOSE an input and it derives what happens. But a real debugging session starts at
the other end: you have a traceback — AttributeError, one line — and no input, and you must
work out what must have been true for that to happen. That is abduction, and it is the analysis
loop run backwards over the hypothesis space. A probe
(experiments/diagnosis.py) proves it:
from experiments.diagnosis import Observation, diagnose, diagnose_and_fix
src = (
"def pipeline(raw):\n"
" data = validate(raw)\n" # data is the validated (non-None) result ...
" data = raw\n" # ... clobbered by the raw input
" return data.rows()\n" # line 4: this is where AttributeError was seen
)
# given ONLY "AttributeError at line 4" — no input — abduce the cause:
dx = diagnose(Observation(source=src, line=4, exc="AttributeError"))
print(dx.explanation()[0])
# root cause: AttributeError at line 4 happens when raw is None
# ...and hand the abduced cause straight to the productized repair axis:
_dx, plan = diagnose_and_fix(Observation(source=src, line=4, exc="AttributeError"))
print(plan.summary()[0]) # 1 edit(s) -> repaired to clean
The mirror is exact — same firmware, run the other way:
| analysis (forward, productized) | diagnosis (this probe) |
|---|---|
| SUPPOSE input → derive every outcome | OBSERVE one outcome → abduce the inputs that entail it |
| the value hypothesis is given | the value hypothesis is the unknown, solved for |
| RECORD trace = why this input crashes | RECORD trace = why this crash happened (the reaching write) |
| CHOOSE the graded-best repair | CHOOSE the graded-best explanation (Occam: the most specific cause) |
| verify a repair by re-execution | verify a cause by re-execution (the same forward analyzer) |
Three moves make it real. (1) The root cause is abduced, not supplied — analyze requires you
to name the None parameter; diagnosis is handed only the crash and searches the hypothesis space
(subsets of parameters supposed None, minimal-first) for the input that reproduces it, recovering
raw plus the reaching-write chain that carried its None past the second assignment to the deref.
(2) CHOOSE picks the minimal cause — many hypotheses reproduce a crash ("everything is None"
always does); the root cause is the smallest set that still does, so an Occam prior is realized as a
graded selection over the public CHOOSE firmware, a single-variable cause outgrading a
supposing-everything one. (3) A suspect is exonerated by re-execution — in a two-parameter
process(cfg, data) where each None crashes a different line, the cause of the line-3 crash is
cfg alone: data's None derives a line-5 crash, so the forward semantics never derive the
observed outcome under it and it never enters the candidate set (trust by the checker, as everywhere
else). And because the abduced cause is a value hypothesis of exactly repair_all's shape,
"understand the root cause" flows straight into "and fix it, verified by re-execution" — the front
half of a debugger wired to the productized repair axis as its back half, with no new machinery. Still
a probe (see tests/test_diagnosis.py); run it:
python -m experiments.diagnosis.
A fourth axis: does the code implement the policy? (conformance)¶
Analysis asks "does this code have a bug?"; conformance asks "does this code do what the business
rule says?" — and answers with a machine-checkable proof. A business policy (in business
vocabulary) and the code (in its own vocabulary) live in one graph, joined only by a small
declarative bridge; scenarios are swept from the policy's own boundary constants; and where the two
disagree, a diverges fact is derived — with a two-world proof and a spec-directed fix. This is the
one thing neither pyright, nor CodeQL, nor a DMN validator, nor an LLM produces: a checkable proof
that a piece of code implements a piece of policy — and a verified minimal edit when it doesn't (a
probe: experiments/conformance_strider.py).
from experiments.conformance_strider import Model, check_and_repair
# a business POLICY and its CODE speak DIFFERENT vocabularies, joined ONLY by a bridge:
# policy (business): a member gets_discount when member_tier is premium and order_spend is over 50
# code (code): def discount(rank, amount): return rank == 'gold' and amount > 100 # ← bug
# bridge: member_tier→rank, order_spend→amount, premium→gold, discount_true→gets_discount
r = check_and_repair(Model())
print(r.divergences)
# ['s_premium_100', 's_premium_51', 's_premium_99']
# (the code DENIES a discount the policy GRANTS, for premium members with spend in (50, 100])
print(r.winner, "→ code_threshold", r.repaired.code_threshold, "| re-sweep:", r.residual_after_repair)
# align_threshold → code_threshold 50 | re-sweep: [] (PROVEN: the repaired code implements the policy)
Three moves make it real. (1) The comparison is a JOIN, not glue — diverges is an ordinary
derived fact (?sc diverges yes when ?sc policy_outcome ?x and ?sc code_outcome ?y and not ?x
same_outcome ?y), so it is queryable and explainable, unlike the brittle imperative glue any
two-tool (rules-engine + tests) architecture would need. (2) The bridge is IN the proof — the
policy and code share no vocabulary; the why {scenario} diverges trace interleaves the business-rule
firing (policy_grants ← member_tier premium), the code-logic firing (code_return discount_false),
and the bridge rule translating between them (discount_false bridges_outcome no_discount). Swap the
bridge and the same policy re-targets a different implementation. (3) Repair is spec-DIRECTED and
proven by re-sweep — align_threshold reads the policy's constant and rewrites the code's, then
re-sweeps to zero divergence (chosen over a decoy edit that fails verification); semantics
preservation ("the code's outcomes equal the policy's on every swept scenario") is the verification
condition by construction. Run it: python -m experiments.conformance_strider.
The value domain that makes this possible — and where it points. Conformance needs the code to
reason about constants and comparisons (amount > 100), which the None-analysis domain
({none, object}) cannot express. A companion probe
(experiments/intake_growth.py) grows exactly that: it intakes a real
Python decision function, reifies its constants + comparisons as data, and derives its return value
by reasoning — pinned against Python execution itself as the oracle. That is the first step of a larger
idea (docs/api_absorption_design.md): move analysis knowledge out
of the rules and into the graph as facts generic rules consume — so that a library's API surface
(dict.get returns_optional, DataFrame has_method groupby) can be absorbed as data, and the
same bridge that maps business terms onto code names maps them onto absorbed library names. Run it:
python -m experiments.intake_growth.
Absorbing a real library, and a bug class it unlocks¶
That idea is now built: pystrider.absorb
reflects a live, annotated module's declared surface into matchable facts — <Type>.<method>
returns_optional yes|no, <Type> has_method <method> — reading only type hints, never running library
code, and conservatively (an undecidable return is omitted and surfaced, never guessed). It runs
on a real dependency: absorb(textual.Widget) yields 73 optional-returning methods, and a generated
returns_optional fact drives the unchanged None-deref effect. On the same facts a second
library-shaped effect falls out — method_not_found: a method call whose receiver type (given, or
inferred through an absorbed return) does not declare the method
(experiments/api_absorption.py, pystrider/absorb.py).
Checking the knowledge itself: rulestrider¶
Because rules are just more graph structure, the same
sweep-and-derive loop turns on a rule bank — the KB-ingestion QA gate that makes LLM-authored policy
trustworthy. A probe (experiments/rulestrider.py) plants a dropped body
condition in a CNL discount policy (the loyalty rule ships requiring only big_spender, not premium
AND big_spender), sweeps an expected-outcome suite, and catches the resulting over-firing on the one
scenario that isolates it — with the why-trace showing the rule firing with the dropped condition
absent: the provenance is the diagnosis. Run it: python -m experiments.rulestrider.
Layout¶
| Path | Role |
|---|---|
pystrider/intake.py |
the §8 code-intake tool — ast → graph facts (structure only, not CNL); CFG + (state×var) cell lattice; per-function namespace for shared graphs |
pystrider/semantics.cnl |
the operational semantics — Horn rules, authored CNL data (semantics.py loads it) |
pystrider/analysis.py |
the hypothesis loop on the public UGM firmware (suppose(commit=False) / ask_goal) + analyze / analyze_return_none / analyze_all / choose_repair / repair_all (whole-function auto-fix) + caveats (surface unmodelled statements) |
pystrider/absorb.py |
the API absorber — reflect a live module's declared type surface into has_method / returns_optional / returns facts (reads hints only, never runs library code; conservative) |
pystrider/session.py |
a Session — several functions in one graph, per-function focus, cross-call value-flow linking |
pystrider/operators.py + operators.cnl |
effect-keyed transformation-operator library, retrieved by backward-CHAIN |
pystrider/transform.py |
transformation mechanism — rewrites the AST to materialize an edit as real source |
grammapy/ |
the in-repo composition algebra — Choice / Accumulate / Scope / Fold + §12 resolve, each soundness check a CNL rule-module over the graph (_cnl.py); the "compose" half of the generation loop |
pystrider/demo.py |
end-to-end packaged walkthrough (python -m pystrider.demo) |
demos/playground/ |
the playground — four swappable CNL blocks (business / UX / library / bridge) brewed into a runnable, Pilot-verified Textual checkout UI; edit a knob and the UI re-derives (README) |
demos/ |
five focused, runnable walkthroughs (python demos/run.py) — see demos/README.md |
experiments/ |
feasibility probes. The humble writer (patterns as rules): pattern_compose.py (compose patterns by intent, repair by intent-mismatch), compose_recover.py / scope_recover.py (compose → check → recover, gated by grammapy + re-execution), footprint_synthesis.py / footprint_scalability.py / footprint_honesty.py (derive a fragment's write-footprint from code + know when it can't). Understanding real code: understand_robustness.py / understand_semantic.py / understand_curve.py / understand_partial.py / understand_partial_curve.py / base_tier.py (recognize code by aspect, over the stdlib corpus). Scale + limit-tests: interaction_scaling.py / retarget_family.py (the win at scale), soundness_redteam.py / economic_test.py / composability_coverage.py / membrane_vagueness.py (the thesis pushed to its edges). Other axes: diagnosis.py (crash → root cause), conformance_strider.py (code⟷policy across a bridge), versioned_recovery.py / versioned_software.py (a program as a build DAG), intake_growth.py, api_absorption.py (absorb a real library + the method_not_found effect), rulestrider.py (anomaly-check a rule bank) |
tests/ |
behaviour pins (284 green) across 42 files — the productized loop (test_spike.py, test_session.py, test_effects.py, test_repair.py, test_repair_verification.py, test_caveats.py, …), the grammapy combinators (test_resolution.py, test_scope.py, test_choice.py, test_fold.py, test_disjointness.py), the humble-writer + understanding probes (test_pattern_compose.py, test_footprint*.py, test_compose_recover.py, test_understand_*.py, test_base_tier.py), the scale + limit-tests (test_interaction_scaling.py, test_retarget_family.py, test_membrane_vagueness.py, …), and the KB pipeline (test_absorb.py, test_method_not_found.py, test_rulestrider.py) |
docs/ |
the strategic roadmap (roadmap.md), the design (code_reasoning_design.md), the understanding findings (understanding_findings.md — the base/concept/intent tiers + limit-tests), the oracle contracts (oracle_contracts.md — what each verdict proves), the API-absorption direction (api_absorption_design.md) |
Run¶
pip install -e ../ugm -e . # the ugm sibling + this package (grammapy ships in-repo, no extra install)
pip install textual # only for the playground (the driven Textual app)
python -m pystrider.demo # the packaged analysis/repair walkthrough
# the generation loop (reason → compose → emit → drive):
python demos/playground/playground.py # a runnable Textual app, verified by driving; turn a knob, re-derive
# the humble writer, understanding, and scale:
python -m experiments.compose_recover # compose → check → recover, gated by grammapy + re-execution
python -m experiments.interaction_scaling # author O(F) vs interaction-audit O(F^2), borne by the checker
python -m experiments.retarget_family # the same decisions drive two libraries, both driven green
# the other reasoning directions and the KB pipeline:
python -m experiments.diagnosis # crash → root cause (abduction) + fix
python -m experiments.conformance_strider # code ⟷ policy conformance across a vocabulary bridge
python -m experiments.api_absorption # absorb a real library + the method_not_found effect
python -m experiments.rulestrider # anomaly-check a CNL rule bank (KB-ingestion QA)
python demos/run.py # the focused analysis/repair demos
pytest -q # the behaviour pins (284 green)