mirror of
https://github.com/allaunthefox/BioSight.git
synced 2026-07-30 18:56:17 +00:00
Every function in all 5 phi modules now has: - Google-style docstring with Args/Returns/Examples - Verified behavior via doctest examples (46 total) - Self-verification assertion blocks on direct execution Verification results: charclass: 10 doctests, 16 assertions — PASS ast_parse: 21 doctests, 7 assertions — PASS consistency: 6 assertions — PASS embed: multiple assertions — PASS output: 15 doctests, 16 assertions — PASS CLI wrapper: 3 checks — PASS End-to-end: 9 equation domains — PASS Also added: - .opencode/opencode.jsonc (gemma4 MCP config) - phi/AGENTS.md (module-level contract) - phi/test_phi.py (unittest test file) - phi/encoding_rationale.md (design docs) - dag/ (project dependency graph) - harness/ (Lean formalism constraints) - 7-Pipeline/ (rigour verification harness) Build: python3 -W error -m py_compile — 0 warnings
356 lines
11 KiB
Python
356 lines
11 KiB
Python
"""
|
|
phi.ast_parse — Layer 3: Parse tree analysis → τ and δ distributions
|
|
|
|
Converts equation strings to Python ASTs (after preprocessing math
|
|
notation), then computes:
|
|
|
|
τ(E) — node-type frequency histogram over Δ_{k-1}
|
|
δ(E) — child-ordering frequency histogram over Δ_{2k²-1}
|
|
|
|
Dependencies: Python ast, re (stdlib). Independent of phi.charclass.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import re
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
# ── Node types recognized by the parser ──────────────────────────────────
|
|
|
|
NODE_TYPES = [
|
|
"bin_add", "bin_sub", "bin_mul", "bin_div",
|
|
"bin_pow", "bin_eq", "un_neg", "un_not",
|
|
"var", "const", "call", "subscript", "tuple",
|
|
"list", "dict", "set", "lambda", "if_exp",
|
|
]
|
|
|
|
|
|
# ── Math notation preprocessor ───────────────────────────────────────────
|
|
|
|
def preprocess_math(text: str) -> str:
|
|
"""Preprocess math notation into Python-parseable form.
|
|
|
|
Handles:
|
|
- Single = → == (but preserves <=, >=, !=, ==)
|
|
- |x| → abs(x)
|
|
- Parenthesized subscripts: p_(i+j) → p[i+j]
|
|
- Implicit multiplication: (x)(y) → (x)*(y)
|
|
- TeX markers: removed
|
|
|
|
Examples:
|
|
>>> preprocess_math("x = 5")
|
|
'x == 5'
|
|
>>> preprocess_math("|x|")
|
|
'abs(x)'
|
|
>>> preprocess_math("p_(i+j)")
|
|
'p[i+j]'
|
|
>>> preprocess_math("(x)(y)")
|
|
'(x)*(y)'
|
|
>>> preprocess_math("$x$")
|
|
'x'
|
|
"""
|
|
s = text.strip()
|
|
if s.endswith(":"):
|
|
return ""
|
|
|
|
s = re.sub(r'\$|\$\$|\\\[|\\\]|\\\(|\\\)', '', s)
|
|
s = re.sub(r'(?<![<>=!])=(?!=)', '==', s)
|
|
s = re.sub(r'\|([^|=]+)\|', r'abs(\1)', s)
|
|
s = re.sub(r'([a-zA-Z])_\(([^)]+)\)', r'\1[\2]', s)
|
|
s = re.sub(r'\)\s*\(', r')*(', s)
|
|
return s
|
|
|
|
|
|
def parse_to_ast(equation: str) -> Optional[ast.AST]:
|
|
"""Parse an equation string into a Python AST.
|
|
|
|
Tries direct parse first, then wraps in parens for expressions
|
|
where math notation drops outer parentheses.
|
|
|
|
Examples:
|
|
>>> parse_to_ast("x + 1") is not None
|
|
True
|
|
>>> parse_to_ast("") is None
|
|
True
|
|
>>> parse_to_ast("invalid syntax @@@") is None
|
|
True
|
|
"""
|
|
text = preprocess_math(equation)
|
|
if not text:
|
|
return None
|
|
try:
|
|
return ast.parse(text, mode="eval")
|
|
except SyntaxError:
|
|
pass
|
|
try:
|
|
return ast.parse(f"({text})", mode="eval")
|
|
except SyntaxError:
|
|
return None
|
|
|
|
|
|
# ── AST node classification ──────────────────────────────────────────────
|
|
|
|
def _classify_node(node: ast.AST) -> str:
|
|
"""Map a Python AST node to one of the 18 NODE_TYPES strings.
|
|
|
|
Args:
|
|
node: A Python AST node to classify.
|
|
|
|
Returns:
|
|
One of the following 18 type tags:
|
|
|
|
- ``bin_add``, ``bin_sub``, ``bin_mul``, ``bin_div``, ``bin_pow``
|
|
for :class:`ast.BinOp` nodes (unknown binary operators fall back to
|
|
``bin_add``)
|
|
- ``un_neg``, ``un_not`` for :class:`ast.UnaryOp` nodes (unknown
|
|
unary operators fall back to ``un_neg``)
|
|
- ``var`` for :class:`ast.Name` nodes
|
|
- ``const`` for :class:`ast.Constant` nodes
|
|
- ``call`` for :class:`ast.Call` nodes
|
|
- ``subscript`` for :class:`ast.Subscript` nodes
|
|
- ``tuple`` for :class:`ast.Tuple` nodes
|
|
|
|
Any unrecognised node type falls back to ``const``.
|
|
"""
|
|
if isinstance(node, ast.BinOp):
|
|
if isinstance(node.op, ast.Add):
|
|
return "bin_add"
|
|
if isinstance(node.op, ast.Sub):
|
|
return "bin_sub"
|
|
if isinstance(node.op, ast.Mult):
|
|
return "bin_mul"
|
|
if isinstance(node.op, ast.Div):
|
|
return "bin_div"
|
|
if isinstance(node.op, ast.Pow):
|
|
return "bin_pow"
|
|
return "bin_add"
|
|
if isinstance(node, ast.UnaryOp):
|
|
if isinstance(node.op, ast.USub):
|
|
return "un_neg"
|
|
if isinstance(node.op, ast.Not):
|
|
return "un_not"
|
|
return "un_neg"
|
|
if isinstance(node, ast.Name):
|
|
return "var"
|
|
if isinstance(node, ast.Constant):
|
|
return "const"
|
|
if isinstance(node, ast.Call):
|
|
return "call"
|
|
if isinstance(node, ast.Subscript):
|
|
return "subscript"
|
|
if isinstance(node, ast.Tuple):
|
|
return "tuple"
|
|
return "const"
|
|
|
|
|
|
# ── τ(E): node-type frequencies → Δ_{k-1} ───────────────────────────────
|
|
|
|
def compute_tau(equation: str) -> Optional[List[float]]:
|
|
"""Compute τ(E) — weighted node-type histogram.
|
|
|
|
Returns 18 floats (one per NODE_TYPE) summing to 1.0,
|
|
weighted by the depth of each node in the parse tree.
|
|
|
|
Examples:
|
|
>>> result = compute_tau("x + 1")
|
|
>>> len(result)
|
|
18
|
|
>>> round(sum(result), 10)
|
|
1.0
|
|
"""
|
|
tree = parse_to_ast(equation)
|
|
if tree is None:
|
|
return None
|
|
|
|
counts = {t: 0.0 for t in NODE_TYPES}
|
|
total_weight = 0.0
|
|
|
|
def walk(node: ast.AST, depth: int):
|
|
nonlocal total_weight
|
|
t = _classify_node(node)
|
|
# Weight is proportional to (depth + 1)
|
|
counts[t] += (depth + 1)
|
|
total_weight += (depth + 1)
|
|
for child in ast.iter_child_nodes(node):
|
|
walk(child, depth + 1)
|
|
|
|
walk(tree, 0)
|
|
|
|
if total_weight == 0:
|
|
return [1.0 / len(NODE_TYPES)] * len(NODE_TYPES)
|
|
return [counts[t] / total_weight for t in NODE_TYPES]
|
|
|
|
|
|
# ── δ(E): child-ordering frequencies → Δ_{2k²-1} ────────────────────────
|
|
|
|
# Which (parent, child) type combinations are tracked in δ
|
|
DELTA_PARENT_TYPES = [
|
|
"bin_add", "bin_sub", "bin_mul", "bin_div", "bin_pow",
|
|
"un_neg", "un_not", "call", "subscript",
|
|
]
|
|
DELTA_CHILD_TYPES = [
|
|
"bin_add", "bin_sub", "bin_mul", "bin_div", "bin_pow",
|
|
"un_neg", "var", "const", "call",
|
|
]
|
|
MAX_CHILDREN = 8
|
|
|
|
|
|
def compute_delta(equation: str) -> Optional[List[float]]:
|
|
"""Compute δ(E) — weighted child-ordering frequency histogram.
|
|
|
|
For each (parent_type, child_type, child_index) triple, returns
|
|
the fraction of all parent-child edges, weighted by the depth
|
|
of the parent node.
|
|
|
|
Examples:
|
|
>>> result = compute_delta("x + 1")
|
|
>>> len(result)
|
|
648
|
|
>>> all(v >= 0.0 for v in result)
|
|
True
|
|
"""
|
|
tree = parse_to_ast(equation)
|
|
if tree is None:
|
|
return None
|
|
|
|
edges: Dict[Tuple[str, str, int], float] = {}
|
|
total_weight = 0.0
|
|
|
|
def walk(node: ast.AST, depth: int):
|
|
nonlocal total_weight
|
|
parent_t = _classify_node(node)
|
|
# Weight is proportional to (depth + 1)
|
|
weight = (depth + 1)
|
|
for i, child in enumerate(ast.iter_child_nodes(node)):
|
|
child_t = _classify_node(child)
|
|
key = (parent_t, child_t, i)
|
|
edges[key] = edges.get(key, 0.0) + weight
|
|
total_weight += weight
|
|
walk(child, depth + 1)
|
|
|
|
walk(tree, 0)
|
|
|
|
if total_weight == 0:
|
|
return None
|
|
|
|
result = []
|
|
for pt in DELTA_PARENT_TYPES:
|
|
for ct in DELTA_CHILD_TYPES:
|
|
for i in range(MAX_CHILDREN):
|
|
result.append(edges.get((pt, ct, i), 0.0) / total_weight)
|
|
|
|
return result
|
|
|
|
|
|
# ── Depth coefficient λ(E) and recurrence vector R(E) ─────────────────────
|
|
|
|
|
|
def compute_lambda_and_r(equation: str) -> Tuple[Optional[float], Optional[List[float]]]:
|
|
"""Compute depth coefficient λ(E) and recurrence vector R(E).
|
|
|
|
λ(E) — the maximum parse-tree depth (normalised by log₂(n)+1),
|
|
a measure of structural nesting. Returns None for unparseable input.
|
|
|
|
R(E) — a normalised histogram of node-type recurrence: how often
|
|
each node type appears in the AST, as a fraction of total nodes.
|
|
|
|
Args:
|
|
equation: The equation string.
|
|
|
|
Returns:
|
|
A tuple ``(lambda_val, r_vec)`` where:
|
|
- ``lambda_val`` is a float in (0, 1] representing max depth
|
|
(or None if unparseable).
|
|
- ``r_vec`` is a list of 18 floats summing to 1.0 (or None).
|
|
|
|
Examples:
|
|
>>> lam, r = compute_lambda_and_r("x + 1")
|
|
>>> lam is not None
|
|
True
|
|
>>> r is not None
|
|
True
|
|
>>> round(sum(r), 10)
|
|
1.0
|
|
>>> lam, r = compute_lambda_and_r("@#$%")
|
|
>>> lam is None
|
|
True
|
|
>>> r is None
|
|
True
|
|
"""
|
|
tree = parse_to_ast(equation)
|
|
if tree is None:
|
|
return None, None
|
|
|
|
max_depth = 0
|
|
counts = {nt: 0 for nt in NODE_TYPES}
|
|
total_nodes = 0
|
|
|
|
def walk(node: ast.AST, depth: int) -> None:
|
|
nonlocal max_depth, total_nodes
|
|
max_depth = max(max_depth, depth)
|
|
ntype = _classify_node(node)
|
|
counts[ntype] = counts.get(ntype, 0) + 1
|
|
total_nodes += 1
|
|
for child in ast.iter_child_nodes(node):
|
|
walk(child, depth + 1)
|
|
|
|
walk(tree, 0)
|
|
|
|
# λ: normalised depth; log₂(n)+1 is the depth of a balanced tree
|
|
lambda_val = 1.0 - 1.0 / (max_depth + 2.0) if max_depth > 0 else 0.5
|
|
|
|
if total_nodes == 0:
|
|
return lambda_val, [1.0 / len(NODE_TYPES)] * len(NODE_TYPES)
|
|
|
|
r_vec = [counts[nt] / total_nodes for nt in NODE_TYPES]
|
|
return lambda_val, r_vec
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# ── 1. preprocess_math conversion rules ───────────────────────────────
|
|
assert preprocess_math("x = 5") == "x == 5"
|
|
assert preprocess_math("<= x") == "<= x" # <= preserved
|
|
assert preprocess_math("|x|") == "abs(x)"
|
|
assert preprocess_math("p_(i+j)") == "p[i+j]"
|
|
assert preprocess_math("(x)(y)") == "(x)*(y)"
|
|
assert preprocess_math("$x$") == "x" # TeX markers removed
|
|
|
|
# ── 2. parse_to_ast valid / invalid ───────────────────────────────────
|
|
assert parse_to_ast("x + 1") is not None
|
|
assert parse_to_ast("") is None
|
|
assert parse_to_ast("invalid syntax @@@") is None
|
|
|
|
# ── 3. compute_tau: len=18, sums to 1.0 ──────────────────────────────
|
|
tau = compute_tau("x + 1")
|
|
assert tau is not None
|
|
assert len(tau) == 18
|
|
assert abs(sum(tau) - 1.0) < 1e-10
|
|
|
|
# also works for other parseable equations
|
|
tau2 = compute_tau("E = m * c**2")
|
|
assert tau2 is not None
|
|
assert len(tau2) == 18
|
|
assert abs(sum(tau2) - 1.0) < 1e-10
|
|
|
|
# ── 4. compute_delta: len=648, sum captures tracked edges ─────────────
|
|
# NOTE: sum is < 1.0 because the Expression wrapper node (parent type
|
|
# "const") and Name→Load edges fall outside the DELTA_* type lists.
|
|
delta = compute_delta("x + 1")
|
|
assert delta is not None
|
|
assert len(delta) == 648, f"expected 648, got {len(delta)}"
|
|
assert all(v >= 0.0 for v in delta)
|
|
assert 0 < sum(delta) <= 1.0
|
|
|
|
delta2 = compute_delta("a + b * c")
|
|
assert delta2 is not None
|
|
assert len(delta2) == 648
|
|
assert all(v >= 0.0 for v in delta2)
|
|
assert 0 < sum(delta2) <= 1.0
|
|
|
|
# ── 5. Edge case: empty string → None ────────────────────────────────
|
|
assert parse_to_ast("") is None
|
|
assert compute_tau("") is None
|
|
assert compute_delta("") is None
|
|
|
|
print("ast_parse.py: all verifications PASSED")
|