diff --git a/README.md b/README.md index 7f9059aa..9c3ff12b 100755 --- a/README.md +++ b/README.md @@ -9,6 +9,16 @@ An explainable inference software supporting annotated, real valued, graph based and temporal logic. +## SRDatalog VulReasoner integration + +This fork includes a reproducible +[VulReasoner integration](minimal_vulreasoner/README.md) that runs this PyReason +checkout as the semantic oracle and evaluates the same interval-valued rules +with SRDatalog. It includes the original rules, annotation callback, default +GraphML input, exact parity checker, bounded performance harnesses, and an +executable notebook explaining the non-semiring provenance gap and two-stage +aggregate lowering. + ## Links [📃 Paper](https://arxiv.org/abs/2302.13482) diff --git a/minimal_vulreasoner/.gitignore b/minimal_vulreasoner/.gitignore new file mode 100644 index 00000000..ea1472ec --- /dev/null +++ b/minimal_vulreasoner/.gitignore @@ -0,0 +1 @@ +output/ diff --git a/minimal_vulreasoner/.python-version b/minimal_vulreasoner/.python-version new file mode 100644 index 00000000..56d91d35 --- /dev/null +++ b/minimal_vulreasoner/.python-version @@ -0,0 +1 @@ +3.10.12 diff --git a/minimal_vulreasoner/README.md b/minimal_vulreasoner/README.md new file mode 100644 index 00000000..a7e2521f --- /dev/null +++ b/minimal_vulreasoner/README.md @@ -0,0 +1,223 @@ +# PyReason to SRDatalog VulReasoner integration + +A self-contained application that exercises **only** the PyReason features +VulReasoner depends on, implements the same reasoning in SRDatalog, and checks +their exact temporal interval parity. This integration lives in the PyReason +fork so PyReason remains the executable oracle while the accelerated backend is +versioned independently. + +## Contents + +| File | Purpose | +|------|---------| +| `run_minimal_reasoner.py` | The example. Loads the KG, one ruleset, the annotation fn, facts, and runs the fixpoint. | +| `run_srdatalog_reasoner.py` | Runs those same inputs through SRDatalog and prints every temporal `AnalystAt` result. | +| `validate_minimal_parity.py` | Runs both engines and compares their complete temporal result maps. | +| `graphml_ingest.py` | Streaming, PyReason-compatible GraphML-to-relation adapter. | +| `analyst_rule_loader.py` | Parser for the constrained six-rule analyst CSV shape. | +| `srdatalog_query.py` | The same six delayed analyst rules encoded in the SRDatalog DSL. | +| `stress_workload.py` | Deterministic layered workloads shared by both engines. | +| `benchmark_pyreason.py` | PyReason oracle and timing harness. | +| `benchmark_srdatalog.py` | SRDatalog build, GPU timing, and result-digest harness. | +| `graphml/CWE_121_MVP2.graphml` | VulReasoner's default knowledge graph with label-to-label ontology edges (`can_cause`, `contributes_to`, `manifestation_of`, …). | +| `rules/analyst_rules.csv` | The six analyst rules whose heads call the annotation function. | +| `annotation_fn.py` | VulReasoner's `paired_minimum_bounds_ann_fn` reference callback. | + +`output/` is created at runtime and holds the rule-trace CSVs (`pr.save_rule_trace`). + +## PyReason surface used + +`pr.reset` → `pr.load_graphml` → `pr.settings.{atom_trace, allow_ground_rules, +save_graph_attributes_to_trace}` → `pr.add_annotation_function` → +`pr.add_rule_from_csv` → `pr.add_closed_world_predicate` → `pr.add_fact` / +`pr.Fact` (hasLabel, analystAt, stepFrom) → `pr.reason` → `pr.save_rule_trace`. + +That is the complete set VulReasoner's reasoning loop touches. + +## PyReason terms in database and provenance language + +PyReason uses `annotation` for several ideas that database systems normally +name separately. The mapping used by the SRDatalog port is: + +| PyReason term | Database/provenance term | +|---|---| +| satisfied or grounded rule body | join result; one derivation witness for the head | +| `qualified_nodes` / `qualified_edges` | the supporting input tuples belonging to each witness | +| body `annotations` | interval-valued payloads carried by those input tuples | +| head annotation function | user-defined grouped aggregate over the witnesses for one rule and head key | +| `paired_minimum_bounds_ann_fn` | rule-local `ARG MAX` by lower bound, carrying the winning witness's interval | +| rule trace | explanation graph / why-provenance trace | +| world | the current keyed relation state at one logical time, not a probabilistic-database possible world | +| world update | duplicate-key interval-lattice merge into that state | +| `<-1` | logical-time shift, represented in SRDatalog by a successor join | + +The callback is **provenance-aware** because it compares alternative +derivation witnesses and returns the interval belonging to the selected +witness. It is not full semiring provenance: classical why-provenance retains +all alternative supporting derivations, whereas the callback deliberately +keeps one maximum-scoring witness. The parity encoding retains its stable rank +and interval, but does not currently materialize a complete provenance +polynomial. + +The compiler-level denotation and the reason for the separate candidate +relation are specified in SRDatalog's +[`docs/ir_lowering_semantics.md`, Section 6.2](https://github.com/harp-lab/srdatalog-python/blob/a5ccfdae531c2f321f5746634eb97515def34b90/docs/ir_lowering_semantics.md#62-provenance-aware-witness-selection). + +## The example workflow + +Four code blocks chained so the analyst's `analystAt` "control" atom propagates +one hop per timestep, from the first block to the `CWE_121` vulnerability class: + +``` +b1 (computed_write_length) + --contributes_to--> b2 (incorrect_length_calculation) [analyst-rule-2] + --can_cause--> b3 (return_address_overwrite) [analyst-rule-1] + --manifestation_of--> b4 (CWE_121) [analyst-rule-5] +``` + +## Run + +```bash +uv sync --project minimal_vulreasoner --frozen +PYTHONHASHSEED=0 uv run --project minimal_vulreasoner --frozen \ + python minimal_vulreasoner/run_minimal_reasoner.py +``` + +The nested uv project pins Python 3.10.12, uses this checkout as its editable +PyReason oracle, pins the validated SRDatalog Git revision, and locks every +transitive dependency in `minimal_vulreasoner/uv.lock`. It is intentionally +independent of any repository-wide development environment. + +Expected tail: + +``` +Converged at time: 4 +analystAt(b4) reached: True +``` + +`PYTHONHASHSEED=0` isn't required for this script, but VulReasoner pins it so its +on-disk traces are byte-reproducible (ADR 0005). + +## Run the existing inputs on SRDatalog + +This command consumes the checked-in `CWE_121_MVP2.graphml`, +`analyst_rules.csv`, and the workflow facts declared in `example_config.py`: + +```bash +uv run --project minimal_vulreasoner --frozen \ + python minimal_vulreasoner/run_srdatalog_reasoner.py \ + --jobs "$(nproc)" +``` + +The runner streams GraphML edge attributes into ordinary relation columns, +parses the six PyReason rules into the supported analyst-rule contract, compiles +the DSL query, loads the relations, runs the GPU fixpoint, and prints one +`RESULT_JSON=...` line. After the first build, reuse the cached shared library: + +```bash +uv run --project minimal_vulreasoner --frozen \ + python minimal_vulreasoner/run_srdatalog_reasoner.py \ + --no-compile +``` + +The exact result is: + +```json +{ + "b1@0": [1.0, 1.0], + "b1@1": [1.0, 1.0], + "b2@2": [1.0, 1.0], + "b3@3": [1.0, 1.0], + "b4@4": [1.0, 1.0] +} +``` + +For a fresh differential comparison against this PyReason checkout: + +```bash +uv run --project minimal_vulreasoner --frozen \ + python minimal_vulreasoner/validate_minimal_parity.py \ + --no-compile +``` + +It compares every `(node,time) -> [lower,upper]` entry and exits nonzero on any +difference. The validated input contains 188 graph nodes, 403 graph edges, and +138 edge-attribute facts selected by the six analyst rules. On an RTX 6000 Ada, +streaming ingestion took about 4 ms, cached GPU execution about 17 ms, and the +one-time CUDA build about 39 seconds. + +## SRDatalog semantics + +The PyReason delay `<-1` is source-level data, not a special GPU scheduling +primitive. `Successor(t,t1)` connects an `AnalystAt` state at `t` to the state +produced at `t1`, and ordinary semi-naive evaluation carries the change. + +`AnalystAt(node,time,lower,upper)` is a functional lattice relation keyed by +`(node,time)`. Duplicate knowledge is joined by interval intersection: + +``` +[l1,u1] join [l2,u2] = [max(l1,l2), min(u1,u2)] +``` + +Each connector join first emits the raw derivation witnesses for one PyReason +rule. A functional candidate relation groups them by `(node,time)` and +materializes the rule-local `ARG MAX`: maximum lower bound, with stable +connector rank as the tie-break key. This is required to reproduce +`paired_minimum_bounds_ann_fn`; intersecting all raw witnesses is not +equivalent. + +Candidate insertion performs the selection during keyed `NEW -> FULL` +maintenance. The following candidate-to-`AnalystAt` rule is only a projection +of the already-selected witness. Insertion into `AnalystAt` then applies the +different, cross-rule interval-intersection operation. A future head aggregate +could fuse these stages, but it could not remove their semantic grouping +boundary. + +Bounds are stored in two columns as bit-cast IEEE-754 binary32 words. All +generated values are in `[0,1]`, where unsigned word order preserves float +order. MIR sees only a generic functional-lattice merge/delta operation; +sorted-array GPU maintenance is one physical realization of it. + +## Stress parity and benchmark + +Run the PyReason oracle in one warm process: + +```bash +uv run --project minimal_vulreasoner --frozen \ + python minimal_vulreasoner/benchmark_pyreason.py \ + --warmup \ + --case 4,4,2 \ + --case 8,32,4 \ + --case 12,128,8 +``` + +Build once and run the same shapes on SRDatalog: + +```bash +uv run --project minimal_vulreasoner --frozen \ + python minimal_vulreasoner/benchmark_srdatalog.py \ + --case 4,4,2 \ + --case 8,32,4 \ + --case 12,128,8 \ + --repeat 2 +``` + +Both harnesses print `target_bounds_sha256`, a SHA-256 digest over every final +target and interval. Equal digests establish exact target-set parity, while +`target_bounds_sample` leaves a few decoded bounds visible for diagnosis. + +On an NVIDIA RTX 6000 Ada, the exact implementation produced these warm +measurements: + +| Depth × width × fanout | Transitions | PyReason reason | SRDatalog run | Digest parity | +|---|---:|---:|---:|---| +| 4 × 4 × 2 | 32 | 8.1 ms | 8.2 ms | exact | +| 8 × 32 × 4 | 1,024 | 351.8 ms | 32.5 ms | exact | +| 12 × 128 × 8 | 12,288 | 29.39 s | 32.7 ms | exact | +| 16 × 256 × 8 | 32,768 | — | 44.8 ms | GPU stress only | + +Compilation is intentionally reported separately (about 49 seconds on this +machine) because generated CUDA is cached and reused. The GPU harness also +uses an explicit process exit after database shutdown to avoid a known +ctypes/CUDA shared-library static-teardown crash; this does not affect query +results or measured execution time. diff --git a/minimal_vulreasoner/__init__.py b/minimal_vulreasoner/__init__.py new file mode 100644 index 00000000..52c748af --- /dev/null +++ b/minimal_vulreasoner/__init__.py @@ -0,0 +1 @@ +"""Minimal VulReasoner parity and stress fixtures.""" diff --git a/minimal_vulreasoner/analyst_rule_loader.py b/minimal_vulreasoner/analyst_rule_loader.py new file mode 100644 index 00000000..e0aa4ab6 --- /dev/null +++ b/minimal_vulreasoner/analyst_rule_loader.py @@ -0,0 +1,141 @@ +'''Parse the constrained PyReason analyst-rule CSV into source-level specs.''' + +from __future__ import annotations + +import csv +import re +from dataclasses import dataclass +from pathlib import Path + +_HEAD = re.compile( + r"^analystAt\((?P[A-Za-z_]\w*)\):(?P[A-Za-z_]\w*)$" +) +_ATOM = re.compile( + r"(?P[A-Za-z_]\w*)\((?P[^)]*)\)" + r"(?:\:\[(?P[0-9.]+),(?P[0-9.]+)\])?" +) + + +@dataclass(frozen=True) +class Bound: + lower: float + upper: float + + def __post_init__(self) -> None: + if not 0.0 <= self.lower <= self.upper <= 1.0: + raise ValueError(f"invalid probability interval [{self.lower},{self.upper}]") + + +@dataclass(frozen=True) +class AnalystRuleSpec: + name: str + connector_predicate: str + annotation_function: str + delay: int + analyst_bound: Bound + cause_label_bound: Bound + effect_label_bound: Bound + connector_bound: Bound + + @property + def relation_name(self) -> str: + return "".join(part.title() for part in self.connector_predicate.split("_")) + + @property + def input_file(self) -> str: + return f"{self.connector_predicate}.csv" + + +@dataclass(frozen=True) +class _AtomSpec: + predicate: str + arguments: tuple[str, ...] + bound: Bound | None + + +def _atom_specs(body: str) -> tuple[_AtomSpec, ...]: + atoms = [] + for match in _ATOM.finditer(body): + lower = match.group("lower") + upper = match.group("upper") + bound = None if lower is None else Bound(float(lower), float(upper)) + atoms.append( + _AtomSpec( + match.group("predicate"), + tuple(arg.strip() for arg in match.group("arguments").split(",")), + bound, + ) + ) + return tuple(atoms) + + +def parse_analyst_rule(rule_text: str, name: str) -> AnalystRuleSpec: + '''Parse and validate the one connector-rule shape supported by this query.''' + pieces = re.split(r"\s+<-(\d+)\s+", rule_text.strip(), maxsplit=1) + if len(pieces) != 3: + raise ValueError(f"{name}: expected a delayed '<-N' rule") + head_text, delay_text, body_text = pieces + head = _HEAD.fullmatch(head_text) + if head is None or head.group("target") != "CB2": + raise ValueError(f"{name}: expected analystAt(CB2):annotation head") + + atoms = _atom_specs(body_text) + analyst = [atom for atom in atoms if atom.predicate == "analystAt"] + labels = [atom for atom in atoms if atom.predicate == "hasLabel"] + steps = [atom for atom in atoms if atom.predicate == "stepFrom"] + connectors = [ + atom + for atom in atoms + if atom.predicate not in {"analystAt", "hasLabel", "stepFrom"} + ] + if len(analyst) != 1 or len(labels) != 2 or len(steps) != 1 or len(connectors) != 1: + raise ValueError( + f"{name}: expected analystAt, two hasLabel clauses, one connector, and stepFrom" + ) + if analyst[0].arguments != ("CB1",) or steps[0].arguments != ("CB1", "CB2"): + raise ValueError(f"{name}: unsupported analystAt/stepFrom variables") + label_by_block = {atom.arguments[0]: atom for atom in labels} + if set(label_by_block) != {"CB1", "CB2"}: + raise ValueError(f"{name}: hasLabel clauses must be keyed by CB1 and CB2") + connector = connectors[0] + expected_connector_args = ( + label_by_block["CB1"].arguments[1], + label_by_block["CB2"].arguments[1], + ) + if connector.arguments != expected_connector_args: + raise ValueError(f"{name}: connector variables do not pair the hasLabel clauses") + bounded = (analyst[0], label_by_block["CB1"], label_by_block["CB2"], connector) + if any(atom.bound is None for atom in bounded): + raise ValueError(f"{name}: every bounded body atom requires [lower,upper]") + + return AnalystRuleSpec( + name=name, + connector_predicate=connector.predicate, + annotation_function=head.group("annotation"), + delay=int(delay_text), + analyst_bound=analyst[0].bound, + cause_label_bound=label_by_block["CB1"].bound, + effect_label_bound=label_by_block["CB2"].bound, + connector_bound=connector.bound, + ) + + +def load_analyst_rules(path: str | Path) -> tuple[AnalystRuleSpec, ...]: + with Path(path).open(newline="") as handle: + rows = csv.DictReader(handle) + required = {"rule_text", "name", "infer_edges", "set_static"} + if rows.fieldnames is None or not required <= set(rows.fieldnames): + raise ValueError(f"analyst rule CSV requires columns {sorted(required)}") + specs = [] + for row in rows: + if row["infer_edges"].strip().lower() != "true": + raise ValueError(f"{row['name']}: analyst connector rule must infer edges") + if row["set_static"].strip().lower() != "false": + raise ValueError(f"{row['name']}: delayed analyst result must not be static") + specs.append(parse_analyst_rule(row["rule_text"], row["name"])) + if not specs: + raise ValueError(f"analyst rule CSV contains no rules: {path}") + predicates = [spec.connector_predicate for spec in specs] + if len(predicates) != len(set(predicates)): + raise ValueError("analyst rule CSV contains duplicate connector predicates") + return tuple(specs) diff --git a/minimal_vulreasoner/annotation_fn.py b/minimal_vulreasoner/annotation_fn.py new file mode 100644 index 00000000..2079a622 --- /dev/null +++ b/minimal_vulreasoner/annotation_fn.py @@ -0,0 +1,96 @@ +""" +PyReason annotation functions used by /reason. + +Kept in its own module so the Docker warmup script (scripts/warmup_pyreason.py) +can import the exact same numba-compiled function the API invokes, without +dragging FastAPI / Pydantic / the rest of api.py through the warmup. Sharing +the function this way also keeps numba's first-class-function cache keys +identical between the warmup pass and the first real /reason call. +""" +import numba + + +# Per-grounding paired ann fn for analyst rules. Recovers the (Lcause, Leffect) +# pairing imposed by the connector clause (can_cause / contributes_to / derives / +# unsafe_variant_of / manifestation_of / implements). For each driver edge it +# looks up matching hasLabel(CB1, Lcause) and hasLabel(CB2, Leffect) bounds, +# takes their per-pair min (intersected with the connector's own bound), then +# returns the pair with the highest lower bound. The driver clause is detected +# dynamically as the edge clause whose two vars are both NOT head vars. +# analystAt(CB1) and stepFrom(CB1, CB2) are excluded from the per-pair min: they +# are gated by body thresholds upstream and equal [1,1] in the workflow. +@numba.njit +def paired_minimum_bounds_ann_fn( + annotations, weights, qualified_nodes, qualified_edges, clause_labels, clause_variables +): + head_var_x = "CB1" + head_var_y = "CB2" + + driver_idx = -1 + has_label_cb1_idx = -1 + has_label_cb2_idx = -1 + + for i in range(len(clause_labels)): + name = clause_labels[i].value + cv = clause_variables[i] + if name == "hasLabel" and len(cv) == 2: + if cv[0] == head_var_x: + has_label_cb1_idx = i + elif cv[0] == head_var_y: + has_label_cb2_idx = i + elif name != "analystAt" and name != "stepFrom": + if ( + len(cv) == 2 + and cv[0] != head_var_x and cv[0] != head_var_y + and cv[1] != head_var_x and cv[1] != head_var_y + ): + driver_idx = i + + if driver_idx < 0 or has_label_cb1_idx < 0 or has_label_cb2_idx < 0: + return 0.0, 1.0 + + best_lower = 0.0 + best_upper = 0.0 + found_any = False + + for ci in range(len(qualified_edges[driver_idx])): + x_val = qualified_edges[driver_idx][ci][0] + y_val = qualified_edges[driver_idx][ci][1] + d_lower = annotations[driver_idx][ci].lower + d_upper = annotations[driver_idx][ci].upper + + x_lower = -1.0 + x_upper = -1.0 + for k in range(len(qualified_edges[has_label_cb1_idx])): + if qualified_edges[has_label_cb1_idx][k][1] == x_val: + x_lower = annotations[has_label_cb1_idx][k].lower + x_upper = annotations[has_label_cb1_idx][k].upper + break + if x_lower < 0.0: + continue + + y_lower = -1.0 + y_upper = -1.0 + for k in range(len(qualified_edges[has_label_cb2_idx])): + if qualified_edges[has_label_cb2_idx][k][1] == y_val: + y_lower = annotations[has_label_cb2_idx][k].lower + y_upper = annotations[has_label_cb2_idx][k].upper + break + if y_lower < 0.0: + continue + + pair_lower = min(min(x_lower, y_lower), d_lower) + pair_upper = min(min(x_upper, y_upper), d_upper) + + if not found_any or pair_lower > best_lower: + best_lower = pair_lower + best_upper = pair_upper + found_any = True + + if not found_any: + return 0.0, 1.0 + lower = min(best_lower, 1.0) + upper = min(best_upper, 1.0) + if lower > upper: + return 0.0, 1.0 + return lower, upper diff --git a/minimal_vulreasoner/benchmark_pyreason.py b/minimal_vulreasoner/benchmark_pyreason.py new file mode 100644 index 00000000..92b4d09c --- /dev/null +++ b/minimal_vulreasoner/benchmark_pyreason.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Run deterministic VulReasoner stress workloads through the PyReason oracle.""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +import networkx as nx +from annotation_fn import paired_minimum_bounds_ann_fn +from stress_workload import ( + emit_csv_dataset, + generate_stress_workload, + summarize_target_bounds, + workload_summary, +) + +HERE = Path(__file__).resolve().parent +RULES_FILE = HERE / "rules" / "analyst_rules.csv" + + +def _load_pyreason(checkout: str | None): + if checkout: + root = Path(checkout).expanduser().resolve() + if not (root / "pyreason" / "__init__.py").exists(): + raise RuntimeError(f"PyReason checkout not found at {root}") + sys.path.insert(0, str(root)) + import pyreason as pr + + return pr + + +def _bounded_fact(predicate: str, left: str, right: str | None, lower: float, upper: float) -> str: + component = left if right is None else f"{left},{right}" + return f"{predicate}({component}):[{lower:.8f},{upper:.8f}]" + + +def _configure(pr, workload, *, parallel: bool) -> None: + pr.reset() + pr.load_graph(nx.DiGraph()) + pr.settings.verbose = False + pr.settings.atom_trace = False + pr.settings.save_graph_attributes_to_trace = False + pr.settings.store_interpretation_changes = False + pr.settings.allow_ground_rules = True + pr.settings.persistent = False + pr.settings.parallel_computing = parallel + pr.add_annotation_function(paired_minimum_bounds_ann_fn) + pr.add_rule_from_csv(str(RULES_FILE), raise_errors=False) + pr.add_closed_world_predicate("analystAt") + + for i, item in enumerate(workload.labels): + pr.add_fact( + pr.Fact( + _bounded_fact("hasLabel", item.block, item.label, item.lower, item.upper), + f"label_{i}", + static=True, + ) + ) + for i, item in enumerate(workload.connectors): + pr.add_fact( + pr.Fact( + _bounded_fact( + item.predicate, + item.source_label, + item.target_label, + item.lower, + item.upper, + ), + f"connector_{i}", + static=True, + ) + ) + for i, step in enumerate(workload.steps): + pr.add_fact( + pr.Fact( + f"stepFrom({step.source},{step.target})", + f"step_{i}", + step.time, + step.time, + ) + ) + for i, seed in enumerate(workload.seeds): + pr.add_fact( + pr.Fact( + _bounded_fact("analystAt", seed.node, None, seed.lower, seed.upper), + f"seed_{i}", + seed.time, + seed.time, + ) + ) + + +def _run_once(pr, workload, *, parallel: bool) -> dict[str, object]: + setup_start = time.perf_counter() + _configure(pr, workload, parallel=parallel) + setup_seconds = time.perf_counter() - setup_start + + reason_start = time.perf_counter() + interpretation = pr.reason(timesteps=workload.depth + 1) + reason_seconds = time.perf_counter() - reason_start + + query_start = time.perf_counter() + reached = 0 + target_bounds: dict[str, tuple[float, float]] = {} + for target in workload.target_nodes: + query = pr.Query(f"analystAt({target}):[0,1]") + bounds = interpretation.query(query, return_bool=False) + target_bounds[target] = (float(bounds[0]), float(bounds[1])) + if bounds != (0, 1) and bounds != (0, 0): + reached += 1 + query_seconds = time.perf_counter() - query_start + + return { + **workload_summary(workload), + "parallel": parallel, + "setup_seconds": setup_seconds, + "reason_seconds": reason_seconds, + "query_seconds": query_seconds, + "total_seconds": setup_seconds + reason_seconds + query_seconds, + "reached_targets": reached, + **summarize_target_bounds(target_bounds), + } + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--depth", type=int, default=4) + parser.add_argument("--width", type=int, default=1) + parser.add_argument("--fanout", type=int, default=1) + parser.add_argument("--repeat", type=int, default=1) + parser.add_argument( + "--case", + action="append", + default=[], + metavar="DEPTH,WIDTH,FANOUT", + help="Run multiple shapes in one warm process; overrides depth/width/fanout", + ) + parser.add_argument( + "--warmup", + action="store_true", + help="Compile PyReason on a 1x1x1 case before reporting requested cases", + ) + parser.add_argument("--parallel", action="store_true") + parser.add_argument("--pyreason-checkout") + parser.add_argument("--emit-dir") + args = parser.parse_args() + + pr = _load_pyreason(args.pyreason_checkout) + if args.warmup: + _run_once(pr, generate_stress_workload(1, 1, 1), parallel=args.parallel) + + if args.case: + shapes: list[tuple[int, int, int]] = [] + for raw in args.case: + try: + shape = tuple(int(value) for value in raw.split(",")) + except ValueError as exc: + raise SystemExit(f"invalid --case {raw!r}; expected DEPTH,WIDTH,FANOUT") from exc + if len(shape) != 3: + raise SystemExit(f"invalid --case {raw!r}; expected DEPTH,WIDTH,FANOUT") + shapes.append(shape) + else: + shapes = [(args.depth, args.width, args.fanout)] + + for depth, width, fanout in shapes: + workload = generate_stress_workload(depth, width, fanout) + if args.emit_dir: + case_dir = Path(args.emit_dir) + if len(shapes) > 1: + case_dir /= f"d{depth}_w{width}_f{fanout}" + emit_csv_dataset(workload, case_dir) + for repeat in range(args.repeat): + result = _run_once(pr, workload, parallel=args.parallel) + result["repeat"] = repeat + print(json.dumps(result, sort_keys=True), flush=True) + + +if __name__ == "__main__": + main() diff --git a/minimal_vulreasoner/benchmark_srdatalog.py b/minimal_vulreasoner/benchmark_srdatalog.py new file mode 100644 index 00000000..49c5d111 --- /dev/null +++ b/minimal_vulreasoner/benchmark_srdatalog.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 +'''Build and benchmark the interval-valued AnalystAt query on SRDatalog.''' + +from __future__ import annotations + +import argparse +import ctypes +import json +import os +import sys +import time +from pathlib import Path + +import cupy as cp + +REPO_SRC = Path(__file__).resolve().parents[1] / "src" +if str(REPO_SRC) not in sys.path: + sys.path.insert(0, str(REPO_SRC)) + +from srdatalog import CompilerConfig, build_project, compile_jit_project +from srdatalog.runtime import ( + cuda_compile_flags, + cuda_include_paths, + cuda_libs, + cuda_link_flags, + runtime_defines, + runtime_include_paths, +) + +try: + from .srdatalog_query import build_analyst_program + from .stress_workload import ( + decode_float32_bits, + emit_csv_dataset, + generate_stress_workload, + summarize_target_bounds, + workload_summary, + ) +except ImportError: + from srdatalog_query import build_analyst_program + from stress_workload import ( + decode_float32_bits, + emit_csv_dataset, + generate_stress_workload, + summarize_target_bounds, + workload_summary, + ) + + +def _compiler_config(jobs: int) -> CompilerConfig: + return CompilerConfig( + include_paths=runtime_include_paths() + cuda_include_paths(), + defines=runtime_defines(), + cxx_flags=cuda_compile_flags() + ["-fPIC"], + link_flags=cuda_link_flags(), + libs=cuda_libs() + ["boost_container"], + shared=True, + jobs=jobs, + ) + + +def _bind(artifact: str): + lib = ctypes.CDLL(artifact, mode=ctypes.RTLD_GLOBAL) + lib.srdatalog_init.restype = ctypes.c_int + lib.srdatalog_load_all.argtypes = [ctypes.c_char_p] + lib.srdatalog_load_all.restype = ctypes.c_int + lib.srdatalog_run.argtypes = [ctypes.c_ulonglong] + lib.srdatalog_run.restype = ctypes.c_int + lib.srdatalog_dev_count.argtypes = [ctypes.c_char_p] + lib.srdatalog_dev_count.restype = ctypes.c_ulonglong + lib.srdatalog_dev_ptr.argtypes = [ctypes.c_char_p, ctypes.c_uint] + lib.srdatalog_dev_ptr.restype = ctypes.c_void_p + lib.srdatalog_shutdown.restype = ctypes.c_int + return lib + + +def _copy_analyst_rows(lib) -> list[tuple[int, int, float, float]]: + count = int(lib.srdatalog_dev_count(b"AnalystAt")) + host_columns = [] + for column in range(4): + pointer = int(lib.srdatalog_dev_ptr(b"AnalystAt", column)) + memory = cp.cuda.UnownedMemory(pointer, count * 4, lib) + device = cp.ndarray( + (count,), + dtype=cp.uint32, + memptr=cp.cuda.MemoryPointer(memory, 0), + ) + host_columns.append(device.get()) + cp.cuda.get_current_stream().synchronize() + return [ + ( + int(host_columns[0][row]), + int(host_columns[1][row]), + decode_float32_bits(int(host_columns[2][row])), + decode_float32_bits(int(host_columns[3][row])), + ) + for row in range(count) + ] + + +def _parse_shapes(args) -> list[tuple[int, int, int]]: + if not args.case: + return [(args.depth, args.width, args.fanout)] + shapes = [] + for raw in args.case: + try: + values = tuple(int(value) for value in raw.split(",")) + except ValueError as exc: + raise SystemExit(f"invalid --case {raw!r}; expected DEPTH,WIDTH,FANOUT") from exc + if len(values) != 3: + raise SystemExit(f"invalid --case {raw!r}; expected DEPTH,WIDTH,FANOUT") + shapes.append(values) + return shapes + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--depth", type=int, default=4) + parser.add_argument("--width", type=int, default=4) + parser.add_argument("--fanout", type=int, default=2) + parser.add_argument("--case", action="append", default=[], metavar="DEPTH,WIDTH,FANOUT") + parser.add_argument("--repeat", type=int, default=1) + parser.add_argument("--cache-base", default="./build") + parser.add_argument("--data-dir", default="./build/vulreasoner_stress") + parser.add_argument("--jobs", type=int, default=8) + parser.add_argument("--no-compile", action="store_true") + args = parser.parse_args() + + emit_start = time.perf_counter() + project = build_project( + build_analyst_program(), + "VulReasonerPlan", + cache_base=args.cache_base, + ) + emit_seconds = time.perf_counter() - emit_start + + compile_seconds = 0.0 + if args.no_compile: + artifacts = list(Path(project["dir"]).glob("*.so")) + if not artifacts: + raise SystemExit(f"no cached shared library in {project['dir']}") + artifact = str(artifacts[0].resolve()) + else: + compile_start = time.perf_counter() + build = compile_jit_project(project, _compiler_config(args.jobs)) + compile_seconds = time.perf_counter() - compile_start + if not build.ok(): + failure = next(result for result in build.compile_results if result.returncode) + raise SystemExit((failure.stderr or failure.stdout)[-12000:]) + artifact = str(Path(build.artifact).resolve()) + + lib = _bind(artifact) + try: + for depth, width, fanout in _parse_shapes(args): + workload = generate_stress_workload(depth, width, fanout) + case_dir = Path(args.data_dir) / f"d{depth}_w{width}_f{fanout}" + emit_csv_dataset(workload, case_dir) + symbols = json.loads((case_dir / "symbols.json").read_text()) + + if lib.srdatalog_init() != 0: + raise RuntimeError("srdatalog_init failed") + load_start = time.perf_counter() + if lib.srdatalog_load_all(str(case_dir.resolve()).encode()) != 0: + raise RuntimeError("srdatalog_load_all failed") + load_seconds = time.perf_counter() - load_start + + for repeat in range(args.repeat): + run_start = time.perf_counter() + if lib.srdatalog_run(0) != 0: + raise RuntimeError("srdatalog_run failed") + run_seconds = time.perf_counter() - run_start + + query_start = time.perf_counter() + rows = _copy_analyst_rows(lib) + by_key = {(node, time_): (lower, upper) for node, time_, lower, upper in rows} + target_bounds = { + target: by_key[(symbols[target], depth)] for target in workload.target_nodes + } + query_seconds = time.perf_counter() - query_start + print( + json.dumps( + { + **workload_summary(workload), + "repeat": repeat, + "emit_seconds": emit_seconds, + "compile_seconds": compile_seconds, + "load_seconds": load_seconds, + "run_seconds": run_seconds, + "query_seconds": query_seconds, + "analyst_rows": len(rows), + **summarize_target_bounds(target_bounds), + }, + sort_keys=True, + ), + flush=True, + ) + lib.srdatalog_shutdown() + finally: + sys.stdout.flush() + sys.stderr.flush() + + # CUDA/RMM owns process-global static resources. A ctypes-loaded DSO can be + # unloaded after those resources, causing a teardown-only crash. The DB has + # already been explicitly shut down, so exit without dlclose destructors. + os._exit(0) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/minimal_vulreasoner/example_config.py b/minimal_vulreasoner/example_config.py new file mode 100644 index 00000000..a675989a --- /dev/null +++ b/minimal_vulreasoner/example_config.py @@ -0,0 +1,21 @@ +'''Source-of-truth inputs shared by the PyReason and SRDatalog runners.''' + +from __future__ import annotations + +from pathlib import Path + +HERE = Path(__file__).resolve().parent +KG_FILE = HERE / "graphml" / "CWE_121_MVP2.graphml" +RULES_FILE = HERE / "rules" / "analyst_rules.csv" +OUTPUT_DIR = HERE / "output" + +# VulReasoner's default horizon. The example settles after the third hop. +END_TIME = 20 + +WORKFLOW = ( + ("b1", "computed_write_length"), + ("b2", "incorrect_length_calculation"), + ("b3", "return_address_overwrite"), + ("b4", "CWE_121"), +) +INITIAL_NODE = "b1" diff --git a/minimal_vulreasoner/graphml/CWE_121_MVP2.graphml b/minimal_vulreasoner/graphml/CWE_121_MVP2.graphml new file mode 100644 index 00000000..9977226c --- /dev/null +++ b/minimal_vulreasoner/graphml/CWE_121_MVP2.graphml @@ -0,0 +1,1795 @@ + + + + + + + + + + + + + + + + + + + + + + VulnerabilityClass + + + CodeEntity + + + CodeEntity + + + CodePattern + + + CodeEntity + + + CodeEntity + + + CodeEntity + + + CodeEntity + + + CodeEntity + + + CodeEntity + + + Function + + + Function + + + Function + + + CodeEntity + + + CodeOperation + + + CodeOperation + + + CodeEntity + + + CodeEntity + + + CodePattern + + + Function + + + CodeEntity + + + CodePattern + + + CodePattern + + + CodeEntity + + + CodeEntity + + + CodeEntity + + + CodePattern + + + CodePattern + + + CodePattern + + + CodeEntity + + + CodePattern + + + CodePattern + + + CodeEntity + + + CodePattern + + + CodePattern + + + CodeOperation + + + CodeEntity + + + CodePattern + + + CodeEntity + + + Beacon + + + Beacon + + + CodeEntity + + + CodeEntity + + + CodeOperation + + + CodeEntity + + + CodePattern + + + CodeEntity + + + Function + + + CodeEntity + + + CodePattern + + + OutcomeLevelFault + + + Function + + + CodeEntity + + + OutcomeLevelFault + + + Beacon + + + Beacon + + + Beacon + + + Function + + + Function + + + Function + + + Function + + + CodePattern + + + FaultCondition + + + FaultCondition + + + FaultCondition + + + CodeEntity + + + CodePattern + + + CodeEntity + + + CodeOperation + + + CodeEntity + + + CodePattern + + + FaultCondition + + + CodeEntity + + + CodeOperation + + + FaultCondition + + + FaultCondition + + + CodeEntity + + + CodeEntity + + + CodeOperation + + + CodeEntity + + + Beacon + + + Beacon + + + Beacon + + + Beacon + + + CodeEntity + + + CodeEntity + + + Function + + + Function + + + Function + + + Function + + + CodeOperation + + + Function + + + CodeEntity + + + FaultCondition + + + CodePattern + + + CodePattern + + + CodeEntity + + + CodePattern + + + CodeEntity + + + FaultCondition + + + CodeEntity + + + Function + + + FaultCondition + + + CodePattern + + + CodeEntity + + + CodeOperation + + + CodeEntity + + + CodeEntity + + + CodePattern + + + CodeEntity + + + CodePattern + + + CodePattern + + + Function + + + CodeEntity + + + Function + + + Function + + + OutcomeLevelFault + + + CodeEntity + + + CodePattern + + + Function + + + CodePattern + + + CodeEntity + + + CodePattern + + + CodePattern + + + FaultCondition + + + CodeEntity + + + CodePattern + + + CodePattern + + + CodeEntity + + + CodePattern + + + CodePattern + + + CodePattern + + + CodeEntity + + + Function + + + CodeEntity + + + Function + + + Function + + + OutcomeLevelFault + + + CodeEntity + + + Function + + + Function + + + Function + + + CodeEntity + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + CodeEntity + + + CodePattern + + + FaultCondition + + + FaultCondition + + + FaultCondition + + + FaultCondition + + + Function + + + CodePattern + + + CodeEntity + + + CodeEntity + + + InputProperty + + + InputProperty + + + InputProperty + + + InputProperty + + + InputProperty + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + Function + + + CodePattern + + + CodePattern + + + Function + + + CodePattern + + + CodeEntity + + + CodeEntity + + + OutcomeLevelFault + + + FaultCondition + + + CodePattern + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + 1 + + + \ No newline at end of file diff --git a/minimal_vulreasoner/graphml_ingest.py b/minimal_vulreasoner/graphml_ingest.py new file mode 100644 index 00000000..afb483ee --- /dev/null +++ b/minimal_vulreasoner/graphml_ingest.py @@ -0,0 +1,287 @@ +'''Stream GraphML into the relation files consumed by the analyst query. + +GraphML is an ingestion format, not part of Datalog semantics. This adapter +matches the subset of PyReason's GraphML attribute interpretation used by the +minimal VulReasoner example and emits deterministic, headerless relation CSVs. +''' + +from __future__ import annotations + +import csv +import json +import sys +import xml.etree.ElementTree as ET +from collections.abc import Iterable +from dataclasses import dataclass +from itertools import pairwise +from pathlib import Path + +REPO_SRC = Path(__file__).resolve().parents[1] / "src" +if REPO_SRC.exists() and str(REPO_SRC) not in sys.path: + sys.path.insert(0, str(REPO_SRC)) + +from srdatalog.value_semantics import float32_to_u32 + +CONNECTOR_PREDICATES = ( + "can_cause", + "contributes_to", + "derives", + "unsafe_variant_of", + "manifestation_of", + "implements", +) + + +@dataclass(frozen=True) +class GraphMLKey: + name: str + domain: str + value_type: str + + +@dataclass(frozen=True) +class ConnectorFact: + predicate: str + source: str + target: str + rank: int + lower: float + upper: float + + +@dataclass(frozen=True) +class ParsedGraphML: + nodes: tuple[str, ...] + connectors: tuple[ConnectorFact, ...] + edge_count: int + + +def _local_name(tag: str) -> str: + return tag.rsplit("}", 1)[-1] + + +def _typed_value(text: str, value_type: str): + if value_type in {"int", "long"}: + return int(text) + if value_type in {"float", "double"}: + return float(text) + if value_type == "boolean": + normalized = text.strip().lower() + if normalized not in {"true", "false", "1", "0"}: + raise ValueError(f"invalid GraphML boolean {text!r}") + return normalized in {"true", "1"} + return text + + +def pyreason_attribute( + name: str, + text: str, + value_type: str = "string", +) -> tuple[str, float, float]: + '''Return PyReason's logical label and interval for one GraphML attribute.''' + value = _typed_value(text.strip(), value_type) + numeric = isinstance(value, (int, float)) and 0 <= value <= 1 + numeric_string = ( + isinstance(value, str) + and value.replace(".", "").isdigit() + and 0 <= float(value) <= 1 + ) + if numeric or numeric_string: + label = name + lower = float(value) + upper = 1.0 + else: + label = f"{name}-{value}" + lower = 1.0 + upper = 1.0 + + if isinstance(value, str): + pieces = value.split(",") + if len(pieces) == 2: + try: + candidate_lower = int(pieces[0]) + candidate_upper = int(pieces[1]) + except (TypeError, ValueError): + pass + else: + if 0 <= candidate_lower <= 1 and 0 <= candidate_upper <= 1: + label = name + lower = float(candidate_lower) + upper = float(candidate_upper) + return label, lower, upper + + +def parse_graphml( + path: str | Path, + *, + connector_predicates: Iterable[str] = CONNECTOR_PREDICATES, +) -> ParsedGraphML: + '''Stream selected edge attributes from a directed GraphML document.''' + selected = frozenset(connector_predicates) + keys: dict[str, GraphMLKey] = {} + nodes: list[str] = [] + connectors: list[ConnectorFact] = [] + edge_rank = 0 + + for event, elem in ET.iterparse(path, events=("start", "end")): + kind = _local_name(elem.tag) + if event == "start" and kind == "graph": + if elem.attrib.get("edgedefault", "directed") != "directed": + raise ValueError("minimal VulReasoner requires directed GraphML") + continue + if event != "end": + continue + if kind == "key": + key_id = elem.attrib.get("id") + name = elem.attrib.get("attr.name", key_id) + if key_id is None or name is None: + raise ValueError("GraphML key requires id and attr.name") + keys[key_id] = GraphMLKey( + name=name, + domain=elem.attrib.get("for", "all"), + value_type=elem.attrib.get("attr.type", "string"), + ) + elem.clear() + elif kind == "node": + node_id = elem.attrib.get("id") + if node_id is None: + raise ValueError("GraphML node requires id") + nodes.append(node_id) + elem.clear() + elif kind == "edge": + source = elem.attrib.get("source") + target = elem.attrib.get("target") + if source is None or target is None: + raise ValueError("GraphML edge requires source and target") + for data in elem: + if _local_name(data.tag) != "data": + continue + key_id = data.attrib.get("key") + if key_id not in keys: + raise ValueError(f"GraphML edge references unknown key {key_id!r}") + key = keys[key_id] + if key.domain not in {"edge", "all"}: + continue + label, lower, upper = pyreason_attribute( + key.name, + data.text or "", + key.value_type, + ) + if label in selected: + connectors.append( + ConnectorFact(label, source, target, edge_rank, lower, upper) + ) + edge_rank += 1 + elem.clear() + + if not nodes: + raise ValueError(f"GraphML document contains no nodes: {path}") + return ParsedGraphML(tuple(nodes), tuple(connectors), edge_rank) + + +def _write_csv(path: Path, rows) -> None: + with path.open("w", newline="") as handle: + csv.writer(handle).writerows(rows) + + +def emit_minimal_dataset( + graphml_path: str | Path, + output_dir: str | Path, + *, + workflow: Iterable[tuple[str, str]], + initial_node: str, + end_time: int, + connector_predicates: Iterable[str] = CONNECTOR_PREDICATES, +) -> Path: + '''Emit the exact extensional relations used by the minimal example.''' + if end_time < 1: + raise ValueError("end_time must be positive") + workflow = tuple(workflow) + if len(workflow) < 2: + raise ValueError("workflow must contain at least two blocks") + block_ids = [block for block, _ in workflow] + if len(set(block_ids)) != len(block_ids): + raise ValueError("workflow block ids must be unique") + if initial_node not in block_ids: + raise ValueError(f"initial node {initial_node!r} is not in the workflow") + + connector_predicates = tuple(connector_predicates) + if not connector_predicates or len(set(connector_predicates)) != len( + connector_predicates + ): + raise ValueError("connector predicates must be non-empty and unique") + graph = parse_graphml( + graphml_path, + connector_predicates=connector_predicates, + ) + graph_nodes = set(graph.nodes) + missing_labels = sorted(label for _, label in workflow if label not in graph_nodes) + if missing_labels: + raise ValueError(f"workflow labels missing from GraphML: {missing_labels}") + + symbols = sorted(graph_nodes | set(block_ids)) + symbol_ids = {symbol: index for index, symbol in enumerate(symbols)} + one = float32_to_u32(1.0) + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + + # The PyReason seed Fact is valid on the inclusive interval [0, 1]. + _write_csv( + out / "analyst_seed.csv", + ((symbol_ids[initial_node], time, one, one) for time in (0, 1)), + ) + _write_csv( + out / "has_label.csv", + ( + (symbol_ids[block], symbol_ids[label], one, one) + for block, label in workflow + ), + ) + + # PyReason asserts workflow edge i on inclusive times [i+1, i+2]. + step_rows = [] + for index, ((source, _), (target, _)) in enumerate(pairwise(workflow)): + for time in (index + 1, index + 2): + step_rows.append((symbol_ids[source], symbol_ids[target], time)) + _write_csv(out / "step_from.csv", step_rows) + _write_csv(out / "successor.csv", ((time, time + 1) for time in range(end_time))) + + by_predicate: dict[str, list[ConnectorFact]] = { + predicate: [] for predicate in connector_predicates + } + for connector in graph.connectors: + by_predicate[connector.predicate].append(connector) + for predicate, facts in by_predicate.items(): + _write_csv( + out / f"{predicate}.csv", + ( + ( + symbol_ids[fact.source], + symbol_ids[fact.target], + fact.rank, + float32_to_u32(fact.lower), + float32_to_u32(fact.upper), + ) + for fact in facts + ), + ) + + manifest = { + "source_graphml": str(Path(graphml_path).resolve()), + "graph_nodes": len(graph.nodes), + "graph_edges": graph.edge_count, + "selected_connector_facts": len(graph.connectors), + "connector_predicates": connector_predicates, + "workflow": workflow, + "initial_node": initial_node, + "end_time": end_time, + "encoding": "IEEE-754 binary32 bit pattern stored as uint32-compatible integer", + "edge_rank": "zero-based GraphML document edge order", + "fact_lifetimes": { + "analyst_seed": [0, 1], + "workflow_step_i": "inclusive [i+1, i+2]", + }, + } + (out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + (out / "symbols.json").write_text(json.dumps(symbol_ids, indent=2) + "\n") + return out diff --git a/minimal_vulreasoner/pyproject.toml b/minimal_vulreasoner/pyproject.toml new file mode 100644 index 00000000..7b883516 --- /dev/null +++ b/minimal_vulreasoner/pyproject.toml @@ -0,0 +1,35 @@ +[project] +name = "srdatalog-vulreasoner-demo" +version = "0.1.0" +description = "Reproducible PyReason and SRDatalog VulReasoner parity application" +requires-python = "==3.10.*" +dependencies = [ + "networkx==3.3", + "numba==0.60.0", + "numpy==1.26.4", + "pandas==2.2.2", + "pyreason", + "setuptools<81", + "srdatalog @ https://github.com/harp-lab/srdatalog-python/archive/a5ccfdae531c2f321f5746634eb97515def34b90.tar.gz", +] + +[dependency-groups] +notebook = [ + "ipykernel==6.29.5", + "ipython==8.39.0", + "jinja2==3.1.6", + "matplotlib==3.10.0", + "nbclient==0.10.4", + "nbformat==5.10.4", +] +gpu-benchmark = [ + "cupy-cuda12x==13.3.0", +] + +[tool.uv] +default-groups = ["notebook", "gpu-benchmark"] +package = false +python-preference = "only-managed" + +[tool.uv.sources] +pyreason = { path = "..", editable = true } diff --git a/minimal_vulreasoner/rules/analyst_rules.csv b/minimal_vulreasoner/rules/analyst_rules.csv new file mode 100644 index 00000000..e33d4f4e --- /dev/null +++ b/minimal_vulreasoner/rules/analyst_rules.csv @@ -0,0 +1,7 @@ +rule_text,name,infer_edges,set_static +"analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lcause):[0.1,1], hasLabel(CB2, Leffect):[0.1,1], can_cause(Lcause, Leffect):[0.1,1], stepFrom(CB1, CB2)",analyst-rule-1,True,False +"analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lcontrib):[0.1,1], hasLabel(CB2, Lfault):[0.1,1], contributes_to(Lcontrib, Lfault):[0.1,1], stepFrom(CB1, CB2)",analyst-rule-2,True,False +"analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lop):[0.1,1], hasLabel(CB2, Lderived):[0.1,1], derives(Lop, Lderived):[0.1,1], stepFrom(CB1, CB2)",analyst-rule-3,True,False +"analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lunsafe):[0.1,1], hasLabel(CB2, Lsafe_concept):[0.1,1], unsafe_variant_of(Lunsafe, Lsafe_concept):[0.1,1], stepFrom(CB1, CB2)",analyst-rule-4,True,False +"analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lfault):[0.1,1], hasLabel(CB2, Lcwe):[0.1,1], manifestation_of(Lfault, Lcwe):[0.1,1], stepFrom(CB1, CB2)",analyst-rule-5,True,False +"analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lfunc):[0.1,1], hasLabel(CB2, Lop):[0.1,1], implements(Lfunc, Lop):[0.1,1], stepFrom(CB1, CB2)",analyst-rule-6,True,False diff --git a/minimal_vulreasoner/run_minimal_reasoner.py b/minimal_vulreasoner/run_minimal_reasoner.py new file mode 100644 index 00000000..d2ab2379 --- /dev/null +++ b/minimal_vulreasoner/run_minimal_reasoner.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +"""Minimal, self-contained VulReasoner PyReason example. + + 1. reset + load a GraphML knowledge graph (pr.reset / pr.load_graphml) + 2. the four settings VulReasoner toggles (pr.settings.*) + 3. register the paired-minimum annotation function (pr.add_annotation_function) + 4. load one ruleset from CSV (pr.add_rule_from_csv) + 5. declare a closed-world predicate (pr.add_closed_world_predicate) + 6. add facts: hasLabel / analystAt / stepFrom (pr.add_fact / pr.Fact) + 7. run the fixpoint (pr.reason) + 8. save the rule trace (pr.save_rule_trace) + +The example workflow is a 4-block chain whose labels are connected in the KG by +``contributes_to`` -> ``can_cause`` -> ``manifestation_of`` edges. That lets the +analyst rules propagate the ``analystAt`` "control" atom one hop per timestep +from the first block all the way to the ``CWE_121`` vulnerability class: + + b1 (computed_write_length) + --contributes_to--> b2 (incorrect_length_calculation) + --can_cause--> b3 (return_address_overwrite) + --manifestation_of--> b4 (CWE_121) + +Run it: + python run_minimal_reasoner.py +""" +from __future__ import annotations + +import pyreason as pr + +try: + from .annotation_fn import paired_minimum_bounds_ann_fn + from .example_config import ( + END_TIME, + INITIAL_NODE, + KG_FILE, + OUTPUT_DIR, + RULES_FILE, + WORKFLOW, + ) +except ImportError: + from annotation_fn import paired_minimum_bounds_ann_fn + from example_config import ( + END_TIME, + INITIAL_NODE, + KG_FILE, + OUTPUT_DIR, + RULES_FILE, + WORKFLOW, + ) + +# The annotation function computes the [lower, upper] bound for a fired analyst +# rule by pairing the cause/effect hasLabel bounds. + +# The minimal "workflow": a list of (code_block_id, label) pairs. Each label is a +# real node in CWE_121_MVP2.graphml, and consecutive labels are joined by a KG +# connector edge that one analyst rule keys off of. +# +# NOTE: the block ids (b1..b4) are deliberately NOT named CB1/CB2 — those are the +# *rule variable* names inside analyst_rules.csv (and the head vars the annotation +# function looks for), not graph nodes. Keeping them distinct avoids confusion. +def configure_engine() -> None: + """Steps 1-3 & 5: load the KG, set engine flags, register the ann fn + CWA.""" + # 1. Reset any prior state and load the knowledge graph. The GraphML carries + # the label->label ontology edges (can_cause, contributes_to, ...) that the + # analyst rules match against. + pr.reset() + pr.load_graphml(str(KG_FILE)) + + # 2. The four settings VulReasoner enables. + pr.settings.atom_trace = True # record which clauses justified each atom + pr.settings.allow_ground_rules = True # treat label constants as ground atoms + pr.settings.save_graph_attributes_to_trace = True + + # 3. Register the annotation function referenced by the analyst rule heads + # (``analystAt(CB2):paired_minimum_bounds_ann_fn <- ...``). It must be + # registered before the rules that name it are loaded/run. + pr.add_annotation_function(paired_minimum_bounds_ann_fn) + + # 5. Closed-world predicate: analystAt is False everywhere it is not proven, + # which lets the negative/threshold clauses behave correctly. + pr.add_closed_world_predicate("analystAt") + + +def load_rules() -> None: + """Step 4: load the single ruleset from CSV.""" + pr.add_rule_from_csv(str(RULES_FILE), raise_errors=False) + + +def add_facts() -> None: + """Step 6: add the hasLabel, analystAt, and stepFrom facts. + + pr.Fact signature used here mirrors VulReasoner: + Fact(fact_text, name, start_time, end_time) # timed fact + Fact(fact_text, name, static=True) # holds for all timesteps + """ + # a) hasLabel(block, label) — static, one per workflow block. + for block_id, label in WORKFLOW: + pr.add_fact(pr.Fact(f"hasLabel({block_id},{label})", f"label-{block_id}", static=True)) + + # b) analystAt(seed) — the "control" atom the analyst starts with, valid at t=0..1. + pr.add_fact(pr.Fact(f"analystAt({INITIAL_NODE})", "initial-control", 0, 1)) + + # c) stepFrom(src,dst) — the workflow edges. Each edge is asserted one timestep + # later than the previous so analystAt propagates exactly one hop per step, + # matching VulReasoner's add_workflow_facts ordering. + for i in range(len(WORKFLOW) - 1): + src = WORKFLOW[i][0] + dst = WORKFLOW[i + 1][0] + pr.add_fact(pr.Fact(f"stepFrom({src},{dst})", f"edge-{i}", i + 1, i + 2)) + + +def main() -> None: + configure_engine() + load_rules() + add_facts() + + # 7. Run the fixpoint. + interpretation = pr.reason(timesteps=END_TIME) + + # 8. Persist the per-rule trace CSVs (edges/nodes) to ./output. + OUTPUT_DIR.mkdir(exist_ok=True) + pr.save_rule_trace(interpretation, str(OUTPUT_DIR)) + + # Print where analystAt ended up so the propagation is visible without opening + # the CSVs. If everything wired up, b1..b4 should all become analystAt=True. + print(f"Reasoning complete. Rule-trace CSVs written to: {OUTPUT_DIR}") + print("Expected: analystAt propagates b1 -> b2 -> b3 -> b4 (CWE_121).") + filtered = interpretation.query(pr.Query("analystAt(b4)")) + print(f"analystAt(b4) reached: {filtered}") + + +if __name__ == "__main__": + main() diff --git a/minimal_vulreasoner/run_srdatalog_reasoner.py b/minimal_vulreasoner/run_srdatalog_reasoner.py new file mode 100644 index 00000000..26bce25e --- /dev/null +++ b/minimal_vulreasoner/run_srdatalog_reasoner.py @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 +'''Run the existing minimal VulReasoner inputs end to end on SRDatalog.''' + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import sys +import time +from pathlib import Path + +REPO_SRC = Path(__file__).resolve().parents[1] / "src" +if str(REPO_SRC) not in sys.path: + sys.path.insert(0, str(REPO_SRC)) + +from srdatalog import build_project, compile_jit_project, float32_to_u32 + +try: + from .benchmark_srdatalog import _bind, _compiler_config, _copy_analyst_rows + from .example_config import END_TIME, INITIAL_NODE, KG_FILE, WORKFLOW + from .graphml_ingest import emit_minimal_dataset + from .srdatalog_query import RULE_SPECS, build_analyst_program +except ImportError: + from benchmark_srdatalog import _bind, _compiler_config, _copy_analyst_rows + from example_config import END_TIME, INITIAL_NODE, KG_FILE, WORKFLOW + from graphml_ingest import emit_minimal_dataset + from srdatalog_query import RULE_SPECS, build_analyst_program + + +def _analyst_digest(rows: dict[str, list[float]]) -> str: + digest = hashlib.sha256() + for key, (lower, upper) in sorted(rows.items()): + digest.update( + f"{key}:{float32_to_u32(lower)}:{float32_to_u32(upper)}\n".encode() + ) + return digest.hexdigest() + + +def _artifact(project: dict[str, object], *, compile_project: bool, jobs: int): + if compile_project: + started = time.perf_counter() + build = compile_jit_project(project, _compiler_config(jobs)) + seconds = time.perf_counter() - started + if not build.ok(): + failure = next(result for result in build.compile_results if result.returncode) + raise RuntimeError((failure.stderr or failure.stdout)[-12000:]) + return str(Path(build.artifact).resolve()), seconds + artifacts = list(Path(project["dir"]).glob("*.so")) + if not artifacts: + raise RuntimeError(f"no cached shared library in {project['dir']}; omit --no-compile") + return str(artifacts[0].resolve()), 0.0 + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--cache-base", default="./build") + parser.add_argument("--data-dir", default="./build/minimal_vulreasoner_graphml") + parser.add_argument("--jobs", type=int, default=8) + parser.add_argument("--no-compile", action="store_true") + args = parser.parse_args() + + ingest_started = time.perf_counter() + data_dir = emit_minimal_dataset( + KG_FILE, + args.data_dir, + workflow=WORKFLOW, + initial_node=INITIAL_NODE, + end_time=END_TIME, + connector_predicates=tuple(spec.connector_predicate for spec in RULE_SPECS), + ) + ingest_seconds = time.perf_counter() - ingest_started + manifest = json.loads((data_dir / "manifest.json").read_text()) + symbols = json.loads((data_dir / "symbols.json").read_text()) + symbol_names = {int(identifier): symbol for symbol, identifier in symbols.items()} + + emit_started = time.perf_counter() + project = build_project( + build_analyst_program(), + "VulReasonerPlan", + cache_base=args.cache_base, + ) + emit_seconds = time.perf_counter() - emit_started + artifact, compile_seconds = _artifact( + project, + compile_project=not args.no_compile, + jobs=args.jobs, + ) + + lib = _bind(artifact) + if lib.srdatalog_init() != 0: + raise RuntimeError("srdatalog_init failed") + load_started = time.perf_counter() + if lib.srdatalog_load_all(str(data_dir.resolve()).encode()) != 0: + raise RuntimeError("srdatalog_load_all failed") + load_seconds = time.perf_counter() - load_started + + run_started = time.perf_counter() + if lib.srdatalog_run(0) != 0: + raise RuntimeError("srdatalog_run failed") + run_seconds = time.perf_counter() - run_started + + query_started = time.perf_counter() + raw_rows = _copy_analyst_rows(lib) + analyst_rows = { + f"{symbol_names[node]}@{time_}": [lower, upper] + for node, time_, lower, upper in raw_rows + } + analyst_rows = dict(sorted(analyst_rows.items())) + query_seconds = time.perf_counter() - query_started + result = { + "engine": "srdatalog", + "graph_nodes": manifest["graph_nodes"], + "graph_edges": manifest["graph_edges"], + "selected_connector_facts": manifest["selected_connector_facts"], + "analyst_rows": analyst_rows, + "analyst_rows_sha256": _analyst_digest(analyst_rows), + "ingest_seconds": ingest_seconds, + "emit_seconds": emit_seconds, + "compile_seconds": compile_seconds, + "load_seconds": load_seconds, + "run_seconds": run_seconds, + "query_seconds": query_seconds, + } + print("Expected temporal chain: b1@0, b1@1, b2@2, b3@3, b4@4") + print("RESULT_JSON=" + json.dumps(result, sort_keys=True), flush=True) + lib.srdatalog_shutdown() + sys.stdout.flush() + sys.stderr.flush() + + # The current ctypes/CUDA DSO has a teardown-order issue after explicit DB + # shutdown. Exit without running dlclose/static destructors. + os._exit(0) + + +if __name__ == "__main__": + main() diff --git a/minimal_vulreasoner/srdatalog_query.py b/minimal_vulreasoner/srdatalog_query.py new file mode 100644 index 00000000..5a2a146c --- /dev/null +++ b/minimal_vulreasoner/srdatalog_query.py @@ -0,0 +1,228 @@ +'''SRDatalog encoding of VulReasoner's provenance-aware analyst aggregate. + +In database terms, each satisfied PyReason rule body is a join result and a +derivation witness: one set of input tuples that jointly supports an +``AnalystAt`` head. ``paired_minimum_bounds_ann_fn`` is a rule-local, grouped +``ARG MAX`` over those why-provenance candidates. It computes one interval per +witness, selects the witness with greatest lower bound, and carries that same +witness's upper bound. Connector rank makes PyReason's first-witness tie +policy explicit; it is a tie-break key, not evidence strength. + +Each ``*Candidate`` relation materializes the grouped-aggregate boundary for +one source rule. The connector join inserts all witness rows into +``Candidate.NEW``; keyed maintenance selects the winner and places only a new +or changed winner in ``Candidate.DELTA``. The promotion rule is only a +projection into ``AnalystAt``--it performs no selection. ``AnalystAt`` then +uses interval intersection to combine the already-selected winners from +different rules. Direct insertion of raw witnesses into ``AnalystAt`` would +incorrectly intersect losing witnesses. + +This reproduces a provenance-aware selection policy but does not materialize +complete semiring provenance: the parity query retains the selected witness's +rank and value, not its full derivation polynomial. A head ``ARG MAX`` could +remove the named candidate relations in a future DSL while preserving the same +logical grouping boundary. + +PyReason's ``<-1`` is an ordinary logical-time shift here: +``Successor(t,t1)`` joins an ``AnalystAt`` state at ``t`` to the head at ``t1``. +Bounds are positive IEEE-754 float32 bit patterns in integer columns, so +integer comparison and ``std::min`` preserve probability order over ``[0,1]``. +''' + +from __future__ import annotations + +from srdatalog import ( + Program, + Relation, + Var, + float32_to_u32, + interval_lattice, + max_lower_lattice, +) +from srdatalog.dsl import Filter, Let + +try: + from .analyst_rule_loader import AnalystRuleSpec, load_analyst_rules + from .example_config import RULES_FILE +except ImportError: + from analyst_rule_loader import AnalystRuleSpec, load_analyst_rules + from example_config import RULES_FILE + +RULE_SPECS = load_analyst_rules(RULES_FILE) +CONNECTORS = tuple((spec.relation_name, spec.input_file) for spec in RULE_SPECS) + + +def _minimum3(left: str, middle: str, right: str) -> str: + return f"std::min(std::min({left}, {middle}), {right})" + + +def build_analyst_program( + rule_specs: tuple[AnalystRuleSpec, ...] = RULE_SPECS, +) -> Program: + if any(spec.delay != 1 for spec in rule_specs): + raise ValueError("the explicit Successor encoding currently supports only <-1") + if any( + spec.annotation_function != "paired_minimum_bounds_ann_fn" + for spec in rule_specs + ): + raise ValueError("unsupported analyst annotation function") + analyst_seed = Relation( + "AnalystSeed", + 4, + column_types=(int, int, int, int), + input_file="analyst_seed.csv", + ) + has_label = Relation( + "HasLabel", + 4, + column_types=(int, int, int, int), + input_file="has_label.csv", + ) + step_from = Relation( + "StepFrom", + 3, + column_types=(int, int, int), + input_file="step_from.csv", + ) + successor = Relation( + "Successor", + 2, + column_types=(int, int), + input_file="successor.csv", + ) + analyst_at = Relation( + "AnalystAt", + 4, + column_types=(int, int, int, int), + print_size=True, + output_file="analyst_at.csv", + value_spec=interval_lattice( + key_columns=(0, 1), + lower_column=2, + upper_column=3, + ), + ) + connectors = tuple((spec.relation_name, spec.input_file) for spec in rule_specs) + connector_relations = [ + Relation( + name, + 5, + column_types=(int, int, int, int, int), + input_file=filename, + ) + for name, filename in connectors + ] + # Materialized state for one rule-local grouped ARG MAX. These are not six + # additional logical inference steps; they preserve which join witness owns + # the interval selected by the PyReason annotation callback. + candidate_relations = [ + Relation( + f"{name}Candidate", + 5, + column_types=(int, int, int, int, int), + value_spec=max_lower_lattice( + key_columns=(0, 1), + rank_column=2, + lower_column=3, + upper_column=4, + ), + ) + for name, _ in connectors + ] + + cb1, cb2 = Var("cb1"), Var("cb2") + time, next_time = Var("time"), Var("next_time") + cause_label, effect_label = Var("cause_label"), Var("effect_label") + analyst_lower, analyst_upper = Var("analyst_lower"), Var("analyst_upper") + cause_lower, cause_upper = Var("cause_lower"), Var("cause_upper") + effect_lower, effect_upper = Var("effect_lower"), Var("effect_upper") + connector_lower, connector_upper = Var("connector_lower"), Var("connector_upper") + connector_rank = Var("connector_rank") + result_lower, result_upper = Var("result_lower"), Var("result_upper") + + rules = [ + ( + analyst_at(cb1, time, analyst_lower, analyst_upper) + <= analyst_seed(cb1, time, analyst_lower, analyst_upper) + ).named("AnalystSeed") + ] + + for connector, candidate, rule_spec in zip( + connector_relations, + candidate_relations, + rule_specs, + ): + analyst_lower_threshold = float32_to_u32(rule_spec.analyst_bound.lower) + analyst_upper_threshold = float32_to_u32(rule_spec.analyst_bound.upper) + cause_lower_threshold = float32_to_u32(rule_spec.cause_label_bound.lower) + cause_upper_threshold = float32_to_u32(rule_spec.cause_label_bound.upper) + effect_lower_threshold = float32_to_u32(rule_spec.effect_label_bound.lower) + effect_upper_threshold = float32_to_u32(rule_spec.effect_label_bound.upper) + connector_lower_threshold = float32_to_u32(rule_spec.connector_bound.lower) + connector_upper_threshold = float32_to_u32(rule_spec.connector_bound.upper) + body = ( + analyst_at(cb1, time, analyst_lower, analyst_upper) + & has_label(cb1, cause_label, cause_lower, cause_upper) + & has_label(cb2, effect_label, effect_lower, effect_upper) + & connector( + cause_label, + effect_label, + connector_rank, + connector_lower, + connector_upper, + ) + & step_from(cb1, cb2, time) + & successor(time, next_time) + & Filter( + vars=( + analyst_lower.name, + analyst_upper.name, + cause_lower.name, + cause_upper.name, + effect_lower.name, + effect_upper.name, + connector_lower.name, + connector_upper.name, + ), + code=( + f"return {analyst_lower.name} >= {analyst_lower_threshold} && " + f"{analyst_upper.name} <= {analyst_upper_threshold} && " + f"{cause_lower.name} >= {cause_lower_threshold} && " + f"{cause_upper.name} <= {cause_upper_threshold} && " + f"{effect_lower.name} >= {effect_lower_threshold} && " + f"{effect_upper.name} <= {effect_upper_threshold} && " + f"{connector_lower.name} >= {connector_lower_threshold} && " + f"{connector_upper.name} <= {connector_upper_threshold};" + ), + ) + & Let( + result_lower.name, + _minimum3(cause_lower.name, effect_lower.name, connector_lower.name), + deps=(cause_lower.name, effect_lower.name, connector_lower.name), + ) + & Let( + result_upper.name, + _minimum3(cause_upper.name, effect_upper.name, connector_upper.name), + deps=(cause_upper.name, effect_upper.name, connector_upper.name), + ) + ) + # The connector join emits raw derivation witnesses. Candidate relation + # maintenance, not this join pipeline, performs the keyed ARG MAX. + rules.append( + ( + candidate(cb2, next_time, connector_rank, result_lower, result_upper) <= body + ).named(f"Analyst{connector.name}") + ) + # This is a projection/copy of the already-selected witness. Insertion + # into AnalystAt applies the distinct cross-rule interval-intersection join. + rules.append( + ( + analyst_at(cb2, next_time, result_lower, result_upper) + <= candidate(cb2, next_time, connector_rank, result_lower, result_upper) + ).named(f"Promote{connector.name}Candidate") + ) + + return Program(rules=rules) + + +PROGRAM = build_analyst_program() diff --git a/minimal_vulreasoner/stress_workload.py b/minimal_vulreasoner/stress_workload.py new file mode 100644 index 00000000..7e658342 --- /dev/null +++ b/minimal_vulreasoner/stress_workload.py @@ -0,0 +1,313 @@ +"""Deterministic VulReasoner-shaped stress datasets. + +The generated relations are shared by the PyReason oracle and the future +SRDatalog implementation. Logical interval bounds remain floats here. CSV +export bit-casts each float32 bound to a uint32-compatible integer so the GPU +path can keep lower and upper as separate 32-bit columns without parsing +floating-point text. +""" + +from __future__ import annotations + +import csv +import hashlib +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +REPO_SRC = Path(__file__).resolve().parents[1] / "src" +if REPO_SRC.exists() and str(REPO_SRC) not in sys.path: + sys.path.insert(0, str(REPO_SRC)) + +from srdatalog.value_semantics import float32_to_u32, u32_to_float32 + +CONNECTOR_PREDICATES = ( + "can_cause", + "contributes_to", + "derives", + "unsafe_variant_of", + "manifestation_of", + "implements", +) + + +@dataclass(frozen=True) +class BoundedNode: + node: str + time: int + lower: float + upper: float + + +@dataclass(frozen=True) +class BoundedLabel: + block: str + label: str + lower: float + upper: float + + +@dataclass(frozen=True) +class TimedStep: + source: str + target: str + time: int + + +@dataclass(frozen=True) +class BoundedConnector: + predicate: str + source_label: str + target_label: str + lower: float + upper: float + + +@dataclass(frozen=True) +class StressWorkload: + depth: int + width: int + fanout: int + seeds: tuple[BoundedNode, ...] + labels: tuple[BoundedLabel, ...] + steps: tuple[TimedStep, ...] + connectors: tuple[BoundedConnector, ...] + successors: tuple[tuple[int, int], ...] + target_nodes: tuple[str, ...] + + @property + def node_count(self) -> int: + return (self.depth + 1) * self.width + + @property + def edge_count(self) -> int: + return len(self.steps) + + +def encode_float32_bits(value: float) -> int: + """Bit-cast a [0,1] float to the corresponding unsigned 32-bit integer.""" + return float32_to_u32(value) + + +def decode_float32_bits(bits: int) -> float: + return u32_to_float32(bits) + + +def _node(layer: int, slot: int) -> str: + return f"b_{layer}_{slot}" + + +def _label(layer: int, slot: int) -> str: + return f"l_{layer}_{slot}" + + +def generate_stress_workload(depth: int, width: int, fanout: int) -> StressWorkload: + """Build a layered workflow with deterministic bounded fanout. + + All nodes in layer zero are seeds. A step from layer ``t`` to ``t+1`` + is active exactly at time ``t``; the analyst rules' delay of one therefore + produces the next layer at time ``t+1``. Target selection is cyclic, so + every layer has the same width and the generated edge count is exactly + ``depth * width * min(width, fanout)``. + """ + if depth < 1: + raise ValueError("depth must be at least 1") + if width < 1: + raise ValueError("width must be at least 1") + if fanout < 1: + raise ValueError("fanout must be at least 1") + + effective_fanout = min(width, fanout) + labels: list[BoundedLabel] = [] + for layer in range(depth + 1): + for slot in range(width): + ordinal = layer * width + slot + lower = 0.50 + 0.01 * (ordinal % 10) + upper = 0.90 + 0.01 * (ordinal % 5) + labels.append(BoundedLabel(_node(layer, slot), _label(layer, slot), lower, upper)) + + steps: list[TimedStep] = [] + connectors: list[BoundedConnector] = [] + for layer in range(depth): + for source_slot in range(width): + for offset in range(effective_fanout): + target_slot = (source_slot + offset) % width + edge_ordinal = len(steps) + predicate = CONNECTOR_PREDICATES[edge_ordinal % len(CONNECTOR_PREDICATES)] + connector_lower = 0.45 + 0.01 * (edge_ordinal % 15) + connector_upper = 0.85 + 0.01 * (edge_ordinal % 10) + steps.append( + TimedStep( + source=_node(layer, source_slot), + target=_node(layer + 1, target_slot), + time=layer, + ) + ) + connectors.append( + BoundedConnector( + predicate=predicate, + source_label=_label(layer, source_slot), + target_label=_label(layer + 1, target_slot), + lower=connector_lower, + upper=connector_upper, + ) + ) + + seeds = tuple(BoundedNode(_node(0, slot), 0, 1.0, 1.0) for slot in range(width)) + targets = tuple(_node(depth, slot) for slot in range(width)) + return StressWorkload( + depth=depth, + width=width, + fanout=effective_fanout, + seeds=seeds, + labels=tuple(labels), + steps=tuple(steps), + connectors=tuple(connectors), + successors=tuple((t, t + 1) for t in range(depth)), + target_nodes=targets, + ) + + +def _write_csv(path: Path, rows) -> None: + with path.open("w", newline="") as f: + writer = csv.writer(f) + writer.writerows(rows) + + +def emit_csv_dataset(workload: StressWorkload, output_dir: str | Path) -> Path: + """Emit one common relation-oriented dataset and return its directory.""" + out = Path(output_dir) + out.mkdir(parents=True, exist_ok=True) + + symbols = sorted( + {item.node for item in workload.seeds} + | {item.block for item in workload.labels} + | {item.label for item in workload.labels} + | {item.source for item in workload.steps} + | {item.target for item in workload.steps} + | {item.source_label for item in workload.connectors} + | {item.target_label for item in workload.connectors} + | set(workload.target_nodes) + ) + symbol_ids = {symbol: idx for idx, symbol in enumerate(symbols)} + + _write_csv( + out / "analyst_seed.csv", + ( + ( + symbol_ids[seed.node], + seed.time, + encode_float32_bits(seed.lower), + encode_float32_bits(seed.upper), + ) + for seed in workload.seeds + ), + ) + _write_csv( + out / "has_label.csv", + ( + ( + symbol_ids[item.block], + symbol_ids[item.label], + encode_float32_bits(item.lower), + encode_float32_bits(item.upper), + ) + for item in workload.labels + ), + ) + _write_csv( + out / "step_from.csv", + ( + (symbol_ids[step.source], symbol_ids[step.target], step.time) + for step in workload.steps + ), + ) + _write_csv( + out / "successor.csv", + workload.successors, + ) + + by_predicate: dict[str, list[tuple[int, BoundedConnector]]] = { + predicate: [] for predicate in CONNECTOR_PREDICATES + } + for rank, connector in enumerate(workload.connectors): + by_predicate[connector.predicate].append((rank, connector)) + for predicate, connectors in by_predicate.items(): + _write_csv( + out / f"{predicate}.csv", + ( + ( + symbol_ids[item.source_label], + symbol_ids[item.target_label], + rank, + encode_float32_bits(item.lower), + encode_float32_bits(item.upper), + ) + for rank, item in connectors + ), + ) + + manifest = { + "depth": workload.depth, + "width": workload.width, + "fanout": workload.fanout, + "node_count": workload.node_count, + "edge_count": workload.edge_count, + "target_nodes": workload.target_nodes, + "target_node_ids": [symbol_ids[node] for node in workload.target_nodes], + "encoding": "IEEE-754 binary32 bit pattern stored as uint32-compatible integer", + "headerless": True, + "schemas": { + "analyst_seed.csv": ["node", "time", "lower_bits", "upper_bits"], + "has_label.csv": ["block", "label", "lower_bits", "upper_bits"], + "step_from.csv": ["source", "target", "time"], + "successor.csv": ["time", "next_time"], + **{ + f"{predicate}.csv": [ + "source_label", + "target_label", + "rank", + "lower_bits", + "upper_bits", + ] + for predicate in CONNECTOR_PREDICATES + }, + }, + } + (out / "manifest.json").write_text(json.dumps(manifest, indent=2) + "\n") + (out / "symbols.json").write_text(json.dumps(symbol_ids, indent=2) + "\n") + return out + + +def workload_summary(workload: StressWorkload) -> dict[str, object]: + """JSON-friendly structural summary used by benchmark reports.""" + return { + "depth": workload.depth, + "width": workload.width, + "fanout": workload.fanout, + "nodes": workload.node_count, + "steps": len(workload.steps), + "labels": len(workload.labels), + "connectors": len(workload.connectors), + "seeds": len(workload.seeds), + "targets": len(workload.target_nodes), + } + + +def summarize_target_bounds( + target_bounds: dict[str, tuple[float, float]], + *, + sample_size: int = 8, +) -> dict[str, object]: + '''Return a compact sample plus a digest over every float32 result.''' + ordered = sorted(target_bounds.items()) + digest = hashlib.sha256() + for target, (lower, upper) in ordered: + digest.update( + f"{target}:{encode_float32_bits(lower)}:{encode_float32_bits(upper)}\n".encode() + ) + return { + "target_bounds_sample": dict(ordered[:sample_size]), + "target_bounds_sha256": digest.hexdigest(), + } diff --git a/minimal_vulreasoner/uv.lock b/minimal_vulreasoner/uv.lock new file mode 100644 index 00000000..9cc5ef9b --- /dev/null +++ b/minimal_vulreasoner/uv.lock @@ -0,0 +1,991 @@ +version = 1 +revision = 3 +requires-python = "==3.10.*" + +[[package]] +name = "appnope" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170, upload-time = "2024-02-06T09:43:11.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321, upload-time = "2024-02-06T09:43:09.663Z" }, +] + +[[package]] +name = "asttokens" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/25/1e/faf0f247f6f881b98fc4d6d07e14085cb89d13665084e6d6ac1dc2c03d0b/asttokens-3.0.2.tar.gz", hash = "sha256:3ecdbd8f2cc195f53ccada3a613538bb5f9ef6f6869129f13e03c30a677b8fe2", size = 63136, upload-time = "2026-07-12T03:31:49.084Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/2b/04b8a15f3a1c77bc79ddf5c73875327f34b4fa75982df2b76e45e402d364/asttokens-3.0.2-py3-none-any.whl", hash = "sha256:9da13157f5b28becde0bd374fc677dcd3c290614264eff096f167c469cd9f933", size = 28702, upload-time = "2026-07-12T03:31:47.542Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "comm" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/13/7d740c5849255756bc17888787313b61fd38a0a8304fc4f073dfc46122aa/comm-0.2.3.tar.gz", hash = "sha256:2dc8048c10962d55d7ad693be1e7045d891b7ce8d999c97963a5e3e99c055971", size = 6319, upload-time = "2025-07-25T14:02:04.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/891a0971e1e4a8c5d2b20bbe0e524dc04548d2307fee33cdeba148fd4fc7/comm-0.2.3-py3-none-any.whl", hash = "sha256:c615d91d75f7f04f095b30d1c1711babd43bdc6419c1be9886a85f2f4e489417", size = 7294, upload-time = "2025-07-25T14:02:02.896Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551, upload-time = "2025-04-15T17:34:46.581Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399, upload-time = "2025-04-15T17:34:51.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061, upload-time = "2025-04-15T17:34:55.961Z" }, + { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956, upload-time = "2025-04-15T17:35:00.992Z" }, + { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872, upload-time = "2025-04-15T17:35:06.177Z" }, + { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027, upload-time = "2025-04-15T17:35:11.244Z" }, + { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641, upload-time = "2025-04-15T17:35:26.701Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075, upload-time = "2025-04-15T17:35:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534, upload-time = "2025-04-15T17:35:46.554Z" }, + { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188, upload-time = "2025-04-15T17:35:50.064Z" }, + { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681, upload-time = "2025-04-15T17:44:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101, upload-time = "2025-04-15T17:45:04.165Z" }, + { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599, upload-time = "2025-04-15T17:45:08.456Z" }, +] + +[[package]] +name = "cupy-cuda12x" +version = "13.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastrlock" }, + { name = "numpy" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/34/60/dc268d1d9c5fdde4673a463feff5e9c70c59f477e647b54b501f65deef60/cupy_cuda12x-13.3.0-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:674488e990998042cc54d2486d3c37cae80a12ba3787636be5a10b9446dd6914", size = 103601326, upload-time = "2024-08-22T07:06:43.653Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/1e19ecf008011df2935d038f26f721f22f2804c00077fc024f088e0996e6/cupy_cuda12x-13.3.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:cf4a2a0864364715881b50012927e88bd7ec1e6f1de3987970870861ae5ed25e", size = 90619949, upload-time = "2024-08-22T07:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/ce/6b/e77e3fc20648d323021f55d4e0fafc5572eff50c37750d6aeae868e110d8/cupy_cuda12x-13.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:7c0dc8c49d271d1c03e49a5d6c8e42e8fee3114b10f269a5ecc387731d693eaa", size = 69594183, upload-time = "2024-08-22T07:06:54.411Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "debugpy" +version = "1.8.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f2/aa/12037145b7a56eaa5b29b41872f7a21b538e807e13f32c4d3c46e59be084/debugpy-1.8.21.tar.gz", hash = "sha256:a3c53278e84c94e11bd87c53970ec391d1a67396c8b22609fcac576520e611a6", size = 1697577, upload-time = "2026-06-01T19:30:35.156Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/f3/6b1d4c71f4cbb5360009f928934a03b42906f28fc7b3f7f35f04e58acead/debugpy-1.8.21-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:8eeab7b5462f683452c57c0126aaa5ec4e974ddb705f39ba87dff8818c8e08f9", size = 2113873, upload-time = "2026-06-01T19:30:37.148Z" }, + { url = "https://files.pythonhosted.org/packages/1c/f2/17c3bf91cebc173bfbf5734cd2669723d0a35c0cf9d2fd2124546efeae83/debugpy-1.8.21-cp310-cp310-manylinux_2_34_x86_64.whl", hash = "sha256:0fddfdc130ac6d8bfc0415b0409822fa901c8f310e5c945ac5653a0352532344", size = 3004715, upload-time = "2026-06-01T19:30:38.888Z" }, + { url = "https://files.pythonhosted.org/packages/5a/22/1f8efd80c7b5909e760f9cfd0c9e8681d2d35d532f7c0a40760cd4da4a19/debugpy-1.8.21-cp310-cp310-win32.whl", hash = "sha256:72b5d676c4cbfac3bac5bb01c138a4656e843f93f03ce2a5f4e394ad49fbee73", size = 5303455, upload-time = "2026-06-01T19:30:40.52Z" }, + { url = "https://files.pythonhosted.org/packages/da/ce/54c79abd6cccef92fa7b43d97e3acafedf4d645557267ece05e948b5e4b8/debugpy-1.8.21-cp310-cp310-win_amd64.whl", hash = "sha256:a7fe47fd23da57b9e0bec3f4a8ee65a2dc55782455ed7f2141d75ab5d2eaeef5", size = 5331751, upload-time = "2026-06-01T19:30:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/51/67e7cf11a53e40694f720457d5b3a1cdaaa3d5a9a633e482f225456b93ff/debugpy-1.8.21-py2.py3-none-any.whl", hash = "sha256:b1e37d333663c8851516a47364ef473da127f9caebe4417e6df6f5825a7e9a92", size = 5352888, upload-time = "2026-06-01T19:31:25.186Z" }, +] + +[[package]] +name = "decorator" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "executing" +version = "2.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/28/c14e053b6762b1044f34a13aab6859bbf40456d37d23aa286ac24cfd9a5d/executing-2.2.1.tar.gz", hash = "sha256:3632cc370565f6648cc328b32435bd120a1e4ebb20c77e3fdde9a13cd1e533c4", size = 1129488, upload-time = "2025-09-01T09:48:10.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/ea/53f2148663b321f21b5a606bd5f191517cf40b7072c0497d3c92c4a13b1e/executing-2.2.1-py2.py3-none-any.whl", hash = "sha256:760643d3452b4d777d295bb167ccc74c64a81df23fb5e08eff250c425a4b2017", size = 28317, upload-time = "2025-09-01T09:48:08.5Z" }, +] + +[[package]] +name = "fastjsonschema" +version = "2.21.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/b5/23b216d9d985a956623b6bd12d4086b60f0059b27799f23016af04a74ea1/fastjsonschema-2.21.2.tar.gz", hash = "sha256:b1eb43748041c880796cd077f1a07c3d94e93ae84bba5ed36800a33554ae05de", size = 374130, upload-time = "2025-08-14T18:49:36.666Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a8/20d0723294217e47de6d9e2e40fd4a9d2f7c4b6ef974babd482a59743694/fastjsonschema-2.21.2-py3-none-any.whl", hash = "sha256:1c797122d0a86c5cace2e54bf4e819c36223b552017172f32c5c024a6b77e463", size = 24024, upload-time = "2025-08-14T18:49:34.776Z" }, +] + +[[package]] +name = "fastrlock" +version = "0.8.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/73/b1/1c3d635d955f2b4bf34d45abf8f35492e04dbd7804e94ce65d9f928ef3ec/fastrlock-0.8.3.tar.gz", hash = "sha256:4af6734d92eaa3ab4373e6c9a1dd0d5ad1304e172b1521733c6c3b3d73c8fa5d", size = 79327, upload-time = "2024-12-17T11:03:39.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/02/3f771177380d8690812d5b2b7736dc6b6c8cd1c317e4572e65f823eede08/fastrlock-0.8.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:cc5fa9166e05409f64a804d5b6d01af670979cdb12cd2594f555cb33cdc155bd", size = 55094, upload-time = "2024-12-17T11:01:49.721Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/aae7ed94b8122c325d89eb91336084596cebc505dc629b795fcc9629606d/fastrlock-0.8.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:7a77ebb0a24535ef4f167da2c5ee35d9be1e96ae192137e9dc3ff75b8dfc08a5", size = 48220, upload-time = "2024-12-17T11:01:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/96/87/9807af47617fdd65c68b0fcd1e714542c1d4d3a1f1381f591f1aa7383a53/fastrlock-0.8.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_24_i686.whl", hash = "sha256:d51f7fb0db8dab341b7f03a39a3031678cf4a98b18533b176c533c122bfce47d", size = 49551, upload-time = "2024-12-17T11:01:52.316Z" }, + { url = "https://files.pythonhosted.org/packages/9d/12/e201634810ac9aee59f93e3953cb39f98157d17c3fc9d44900f1209054e9/fastrlock-0.8.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:767ec79b7f6ed9b9a00eb9ff62f2a51f56fdb221c5092ab2dadec34a9ccbfc6e", size = 49398, upload-time = "2024-12-17T11:01:53.514Z" }, + { url = "https://files.pythonhosted.org/packages/15/a1/439962ed439ff6f00b7dce14927e7830e02618f26f4653424220a646cd1c/fastrlock-0.8.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d6a77b3f396f7d41094ef09606f65ae57feeb713f4285e8e417f4021617ca62", size = 53334, upload-time = "2024-12-17T11:01:55.518Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9e/1ae90829dd40559ab104e97ebe74217d9da794c4bb43016da8367ca7a596/fastrlock-0.8.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:92577ff82ef4a94c5667d6d2841f017820932bc59f31ffd83e4a2c56c1738f90", size = 52495, upload-time = "2024-12-17T11:01:57.76Z" }, + { url = "https://files.pythonhosted.org/packages/e5/8c/5e746ee6f3d7afbfbb0d794c16c71bfd5259a4e3fb1dda48baf31e46956c/fastrlock-0.8.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3df8514086e16bb7c66169156a8066dc152f3be892c7817e85bf09a27fa2ada2", size = 51972, upload-time = "2024-12-17T11:02:01.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/a7/8b91068f00400931da950f143fa0f9018bd447f8ed4e34bed3fe65ed55d2/fastrlock-0.8.3-cp310-cp310-win_amd64.whl", hash = "sha256:001fd86bcac78c79658bac496e8a17472d64d558cd2227fdc768aa77f877fe40", size = 30946, upload-time = "2024-12-17T11:02:03.491Z" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "ipykernel" +version = "6.29.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "appnope", marker = "sys_platform == 'darwin'" }, + { name = "comm" }, + { name = "debugpy" }, + { name = "ipython" }, + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "matplotlib-inline" }, + { name = "nest-asyncio" }, + { name = "packaging" }, + { name = "psutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367, upload-time = "2024-07-01T14:07:22.543Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", size = 117173, upload-time = "2024-07-01T14:07:19.603Z" }, +] + +[[package]] +name = "ipython" +version = "8.39.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "exceptiongroup" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/18/f8598d287006885e7136451fdea0755af4ebcbfe342836f24deefaed1164/ipython-8.39.0.tar.gz", hash = "sha256:4110ae96012c379b8b6db898a07e186c40a2a1ef5d57a7fa83166047d9da7624", size = 5513971, upload-time = "2026-03-27T10:02:13.94Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/56/4cc7fc9e9e3f38fd324f24f8afe0ad8bb5fa41283f37f1aaf9de0612c968/ipython-8.39.0-py3-none-any.whl", hash = "sha256:bb3c51c4fa8148ab1dea07a79584d1c854e234ea44aa1283bcb37bc75054651f", size = 831849, upload-time = "2026-03-27T10:02:07.846Z" }, +] + +[[package]] +name = "jedi" +version = "0.20.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "jupyter-client" +version = "8.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "python-dateutil" }, + { name = "pyzmq" }, + { name = "tornado" }, + { name = "traitlets" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/dc/5512503b088997c2250b8bf18258fba9d9ce5ead641183700960d3c9d342/jupyter_client-8.9.1.tar.gz", hash = "sha256:a58f730dd9e728ba16ba1d62ebccf7ffe1ebbdbce4e95cfae941b7321ae1f4fa", size = 359256, upload-time = "2026-06-09T13:15:01.033Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/6f/56d39bf385c5c27988aebaf0c18a2a17e960575740100973511018bd904e/jupyter_client-8.9.1-py3-none-any.whl", hash = "sha256:0b7a295bc46e8751e9adae84781f726c851c1d911bd793edc4a3bde942e3da81", size = 109828, upload-time = "2026-06-09T13:14:58.835Z" }, +] + +[[package]] +name = "jupyter-core" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/49/9d1284d0dc65e2c757b74c6687b6d319b02f822ad039e5c512df9194d9dd/jupyter_core-5.9.1.tar.gz", hash = "sha256:4d09aaff303b9566c3ce657f580bd089ff5c91f5f89cf7d8846c3cdf465b5508", size = 89814, upload-time = "2025-10-16T19:19:18.444Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/e7/80988e32bf6f73919a113473a604f5a8f09094de312b9d52b79c2df7612b/jupyter_core-5.9.1-py3-none-any.whl", hash = "sha256:ebf87fdc6073d142e114c72c9e29a9d7ca03fad818c5d300ce2adc1fb0743407", size = 29032, upload-time = "2025-10-16T19:19:16.783Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/f8/06549565caa026e540b7e7bab5c5a90eb7ca986015f4c48dace243cd24d9/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:32cc0a5365239a6ea0c6ed461e8838d053b57e397443c0ca894dcc8e388d4374", size = 122802, upload-time = "2026-03-09T13:12:37.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/eb/8476a0818850c563ff343ea7c9c05dcdcbd689a38e01aa31657df01f91fa/kiwisolver-1.5.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cc0b66c1eec9021353a4b4483afb12dfd50e3669ffbb9152d6842eb34c7e29fd", size = 66216, upload-time = "2026-03-09T13:12:38.812Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/f9c8a6b4c21aed4198566e45923512986d6cef530e7263b3a5f823546561/kiwisolver-1.5.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:86e0287879f75621ae85197b0877ed2f8b7aa57b511c7331dce2eb6f4de7d476", size = 63917, upload-time = "2026-03-09T13:12:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0e/ba4ae25d03722f64de8b2c13e80d82ab537a06b30fc7065183c6439357e3/kiwisolver-1.5.0-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:62f59da443c4f4849f73a51a193b1d9d258dcad0c41bc4d1b8fb2bcc04bfeb22", size = 1628776, upload-time = "2026-03-09T13:12:41.976Z" }, + { url = "https://files.pythonhosted.org/packages/8a/e4/3f43a011bc8a0860d1c96f84d32fa87439d3feedf66e672fef03bf5e8bac/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9190426b7aa26c5229501fa297b8d0653cfd3f5a36f7990c264e157cbf886b3b", size = 1228164, upload-time = "2026-03-09T13:12:44.002Z" }, + { url = "https://files.pythonhosted.org/packages/4b/34/3a901559a1e0c218404f9a61a93be82d45cb8f44453ba43088644980f033/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c8277104ded0a51e699c8c3aff63ce2c56d4ed5519a5f73e0fd7057f959a2b9e", size = 1246656, upload-time = "2026-03-09T13:12:45.557Z" }, + { url = "https://files.pythonhosted.org/packages/87/9e/f78c466ea20527822b95ad38f141f2de1dcd7f23fb8716b002b0d91bbe59/kiwisolver-1.5.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8f9baf6f0a6e7571c45c8863010b45e837c3ee1c2c77fcd6ef423be91b21fedb", size = 1295562, upload-time = "2026-03-09T13:12:47.562Z" }, + { url = "https://files.pythonhosted.org/packages/0a/66/fd0e4a612e3a286c24e6d6f3a5428d11258ed1909bc530ba3b59807fd980/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cff8e5383db4989311f99e814feeb90c4723eb4edca425b9d5d9c3fefcdd9537", size = 2178473, upload-time = "2026-03-09T13:12:50.254Z" }, + { url = "https://files.pythonhosted.org/packages/dc/8e/6cac929e0049539e5ee25c1ee937556f379ba5204840d03008363ced662d/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ebae99ed6764f2b5771c522477b311be313e8841d2e0376db2b10922daebbba4", size = 2274035, upload-time = "2026-03-09T13:12:51.785Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d3/9d0c18f1b52ea8074b792452cf17f1f5a56bd0302a85191f405cfbf9da16/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:d5cd5189fc2b6a538b75ae45433140c4823463918f7b1617c31e68b085c0022c", size = 2443217, upload-time = "2026-03-09T13:12:53.329Z" }, + { url = "https://files.pythonhosted.org/packages/45/2a/6e19368803a038b2a90857bf4ee9e3c7b667216d045866bf22d3439fd75e/kiwisolver-1.5.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f42c23db5d1521218a3276bb08666dcb662896a0be7347cba864eca45ff64ede", size = 2249196, upload-time = "2026-03-09T13:12:55.057Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/3f641dfcbe72e222175d626bacf2f72c3b34312afec949dd1c50afa400f5/kiwisolver-1.5.0-cp310-cp310-win_amd64.whl", hash = "sha256:94eff26096eb5395136634622515b234ecb6c9979824c1f5004c6e3c3c85ccd2", size = 73389, upload-time = "2026-03-09T13:12:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/da/88/299b137b9e0025d8982e03d2d52c123b0a2b159e84b0ef1501ef446339cf/kiwisolver-1.5.0-cp310-cp310-win_arm64.whl", hash = "sha256:dd952e03bfbb096cfe2dd35cd9e00f269969b67536cb4370994afc20ff2d0875", size = 64782, upload-time = "2026-03-09T13:12:57.609Z" }, + { url = "https://files.pythonhosted.org/packages/17/6f/6fd4f690a40c2582fa34b97d2678f718acf3706b91d270c65ecb455d0a06/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:295d9ffe712caa9f8a3081de8d32fc60191b4b51c76f02f951fd8407253528f4", size = 59606, upload-time = "2026-03-09T13:15:40.81Z" }, + { url = "https://files.pythonhosted.org/packages/82/a0/2355d5e3b338f13ce63f361abb181e3b6ea5fffdb73f739b3e80efa76159/kiwisolver-1.5.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:51e8c4084897de9f05898c2c2a39af6318044ae969d46ff7a34ed3f96274adca", size = 57537, upload-time = "2026-03-09T13:15:42.071Z" }, + { url = "https://files.pythonhosted.org/packages/c8/b9/1d50e610ecadebe205b71d6728fd224ce0e0ca6aba7b9cbe1da049203ac5/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b83af57bdddef03c01a9138034c6ff03181a3028d9a1003b301eb1a55e161a3f", size = 79888, upload-time = "2026-03-09T13:15:43.317Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ee/b85ffcd75afed0357d74f0e6fc02a4507da441165de1ca4760b9f496390d/kiwisolver-1.5.0-pp310-pypy310_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf4679a3d71012a7c2bf360e5cd878fbd5e4fcac0896b56393dec239d81529ed", size = 77584, upload-time = "2026-03-09T13:15:44.605Z" }, + { url = "https://files.pythonhosted.org/packages/6b/dd/644d0dde6010a8583b4cd66dd41c5f83f5325464d15c4f490b3340ab73b4/kiwisolver-1.5.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:41024ed50e44ab1a60d3fe0a9d15a4ccc9f5f2b1d814ff283c8d01134d5b81bc", size = 73390, upload-time = "2026-03-09T13:15:45.832Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.43.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/3d/f513755f285db51ab363a53e898b85562e950f79a2e6767a364530c2f645/llvmlite-0.43.0.tar.gz", hash = "sha256:ae2b5b5c3ef67354824fb75517c8db5fbe93bc02cd9671f3c62271626bc041d5", size = 157069, upload-time = "2024-06-13T18:09:32.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/23/ff/6ca7e98998b573b4bd6566f15c35e5c8bea829663a6df0c7aa55ab559da9/llvmlite-0.43.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:a289af9a1687c6cf463478f0fa8e8aa3b6fb813317b0d70bf1ed0759eab6f761", size = 31064408, upload-time = "2024-06-13T18:08:13.462Z" }, + { url = "https://files.pythonhosted.org/packages/ca/5c/a27f9257f86f0cda3f764ff21d9f4217b9f6a0d45e7a39ecfa7905f524ce/llvmlite-0.43.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6d4fd101f571a31acb1559ae1af30f30b1dc4b3186669f92ad780e17c81e91bc", size = 28793153, upload-time = "2024-06-13T18:08:17.336Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/4410f670ad0a911227ea2ecfcba9f672a77cf1924df5280c4562032ec32d/llvmlite-0.43.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d434ec7e2ce3cc8f452d1cd9a28591745de022f931d67be688a737320dfcead", size = 42857276, upload-time = "2024-06-13T18:08:21.071Z" }, + { url = "https://files.pythonhosted.org/packages/c6/21/2ffbab5714e72f2483207b4a1de79b2eecd9debbf666ff4e7067bcc5c134/llvmlite-0.43.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6912a87782acdff6eb8bf01675ed01d60ca1f2551f8176a300a886f09e836a6a", size = 43871781, upload-time = "2024-06-13T18:08:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/b5478037c453554a61625ef1125f7e12bb1429ae11c6376f47beba9b0179/llvmlite-0.43.0-cp310-cp310-win_amd64.whl", hash = "sha256:14f0e4bf2fd2d9a75a3534111e8ebeb08eda2f33e9bdd6dfa13282afacdde0ed", size = 28123487, upload-time = "2024-06-13T18:08:30.348Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/dd/fa2e1a45fce2d09f4aea3cee169760e672c8262325aa5796c49d543dc7e6/matplotlib-3.10.0.tar.gz", hash = "sha256:b886d02a581b96704c9d1ffe55709e49b4d2d52709ccebc4be42db856e511278", size = 36686418, upload-time = "2024-12-14T06:32:51.547Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/09/ec/3cdff7b5239adaaacefcc4f77c316dfbbdf853c4ed2beec467e0fec31b9f/matplotlib-3.10.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2c5829a5a1dd5a71f0e31e6e8bb449bc0ee9dbfb05ad28fc0c6b55101b3a4be6", size = 8160551, upload-time = "2024-12-14T06:30:36.73Z" }, + { url = "https://files.pythonhosted.org/packages/41/f2/b518f2c7f29895c9b167bf79f8529c63383ae94eaf49a247a4528e9a148d/matplotlib-3.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a2a43cbefe22d653ab34bb55d42384ed30f611bcbdea1f8d7f431011a2e1c62e", size = 8034853, upload-time = "2024-12-14T06:30:40.973Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8d/45754b4affdb8f0d1a44e4e2bcd932cdf35b256b60d5eda9f455bb293ed0/matplotlib-3.10.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:607b16c8a73943df110f99ee2e940b8a1cbf9714b65307c040d422558397dac5", size = 8446724, upload-time = "2024-12-14T06:30:45.325Z" }, + { url = "https://files.pythonhosted.org/packages/09/5a/a113495110ae3e3395c72d82d7bc4802902e46dc797f6b041e572f195c56/matplotlib-3.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01d2b19f13aeec2e759414d3bfe19ddfb16b13a1250add08d46d5ff6f9be83c6", size = 8583905, upload-time = "2024-12-14T06:30:50.869Z" }, + { url = "https://files.pythonhosted.org/packages/12/b1/8b1655b4c9ed4600c817c419f7eaaf70082630efd7556a5b2e77a8a3cdaf/matplotlib-3.10.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e6c6461e1fc63df30bf6f80f0b93f5b6784299f721bc28530477acd51bfc3d1", size = 9395223, upload-time = "2024-12-14T06:30:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/5a/85/b9a54d64585a6b8737a78a61897450403c30f39e0bd3214270bb0b96f002/matplotlib-3.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:994c07b9d9fe8d25951e3202a68c17900679274dadfc1248738dcfa1bd40d7f3", size = 8025355, upload-time = "2024-12-14T06:30:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/32/5f/29def7ce4e815ab939b56280976ee35afffb3bbdb43f332caee74cb8c951/matplotlib-3.10.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:81713dd0d103b379de4516b861d964b1d789a144103277769238c732229d7f03", size = 8155500, upload-time = "2024-12-14T06:32:36.849Z" }, + { url = "https://files.pythonhosted.org/packages/de/6d/d570383c9f7ca799d0a54161446f9ce7b17d6c50f2994b653514bcaa108f/matplotlib-3.10.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:359f87baedb1f836ce307f0e850d12bb5f1936f70d035561f90d41d305fdacea", size = 8032398, upload-time = "2024-12-14T06:32:40.198Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b4/680aa700d99b48e8c4393fa08e9ab8c49c0555ee6f4c9c0a5e8ea8dfde5d/matplotlib-3.10.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ae80dc3a4add4665cf2faa90138384a7ffe2a4e37c58d83e115b54287c4f06ef", size = 8587361, upload-time = "2024-12-14T06:32:43.575Z" }, +] + +[[package]] +name = "matplotlib-inline" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, +] + +[[package]] +name = "memory-profiler" +version = "0.61.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "psutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/88/e1907e1ca3488f2d9507ca8b0ae1add7b1cd5d3ca2bc8e5b329382ea2c7b/memory_profiler-0.61.0.tar.gz", hash = "sha256:4e5b73d7864a1d1292fb76a03e82a3e78ef934d06828a698d9dada76da2067b0", size = 35935, upload-time = "2022-11-15T17:57:28.994Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/26/aaca612a0634ceede20682e692a6c55e35a94c21ba36b807cc40fe910ae1/memory_profiler-0.61.0-py3-none-any.whl", hash = "sha256:400348e61031e3942ad4d4109d18753b2fb08c2f6fb8290671c5513a34182d84", size = 31803, upload-time = "2022-11-15T17:57:27.031Z" }, +] + +[[package]] +name = "nbclient" +version = "0.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-client" }, + { name = "jupyter-core" }, + { name = "nbformat" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/56/91/1c1d5a4b9a9ebba2b4e32b8c852c2975c872aec1fe42ab5e516b2cecd193/nbclient-0.10.4.tar.gz", hash = "sha256:1e54091b16e6da39e297b0ece3e10f6f29f4ac4e8ee515d29f8a7099bd6553c9", size = 62554, upload-time = "2025-12-23T07:45:46.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/a0/5b0c2f11142ed1dddec842457d3f65eaf71a0080894eb6f018755b319c3a/nbclient-0.10.4-py3-none-any.whl", hash = "sha256:9162df5a7373d70d606527300a95a975a47c137776cd942e52d9c7e29ff83440", size = 25465, upload-time = "2025-12-23T07:45:44.51Z" }, +] + +[[package]] +name = "nbformat" +version = "5.10.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastjsonschema" }, + { name = "jsonschema" }, + { name = "jupyter-core" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749, upload-time = "2024-04-04T11:20:37.371Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454, upload-time = "2024-04-04T11:20:34.895Z" }, +] + +[[package]] +name = "nest-asyncio" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, +] + +[[package]] +name = "networkx" +version = "3.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/e6/b164f94c869d6b2c605b5128b7b0cfe912795a87fc90e78533920001f3ec/networkx-3.3.tar.gz", hash = "sha256:0c127d8b2f4865f59ae9cb8aafcd60b5c70f3241ebd66f7defad7c4ab90126c9", size = 2126579, upload-time = "2024-04-06T12:59:47.137Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/e9/5f72929373e1a0e8d142a130f3f97e6ff920070f87f91c4e13e40e0fba5a/networkx-3.3-py3-none-any.whl", hash = "sha256:28575580c6ebdaf4505b22c6256a2b9de86b316dc63ba9e93abde3d78dfdbcf2", size = 1702396, upload-time = "2024-04-06T12:59:44.283Z" }, +] + +[[package]] +name = "numba" +version = "0.60.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3c/93/2849300a9184775ba274aba6f82f303343669b0592b7bb0849ea713dabb0/numba-0.60.0.tar.gz", hash = "sha256:5df6158e5584eece5fc83294b949fd30b9f1125df7708862205217e068aabf16", size = 2702171, upload-time = "2024-06-13T18:11:19.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/cf/baa13a7e3556d73d9e38021e6d6aa4aeb30d8b94545aa8b70d0f24a1ccc4/numba-0.60.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:5d761de835cd38fb400d2c26bb103a2726f548dc30368853121d66201672e651", size = 2647627, upload-time = "2024-06-13T18:10:29.857Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ba/4b57fa498564457c3cc9fc9e570a6b08e6086c74220f24baaf04e54b995f/numba-0.60.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:159e618ef213fba758837f9837fb402bbe65326e60ba0633dbe6c7f274d42c1b", size = 2650322, upload-time = "2024-06-13T18:10:32.849Z" }, + { url = "https://files.pythonhosted.org/packages/28/98/7ea97ee75870a54f938a8c70f7e0be4495ba5349c5f9db09d467c4a5d5b7/numba-0.60.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1527dc578b95c7c4ff248792ec33d097ba6bef9eda466c948b68dfc995c25781", size = 3407390, upload-time = "2024-06-13T18:10:34.741Z" }, + { url = "https://files.pythonhosted.org/packages/79/58/cb4ac5b8f7ec64200460aef1fed88258fb872ceef504ab1f989d2ff0f684/numba-0.60.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe0b28abb8d70f8160798f4de9d486143200f34458d34c4a214114e445d7124e", size = 3699694, upload-time = "2024-06-13T18:10:37.295Z" }, + { url = "https://files.pythonhosted.org/packages/1c/b0/c61a93ca947d12233ff45de506ddbf52af3f752066a0b8be4d27426e16da/numba-0.60.0-cp310-cp310-win_amd64.whl", hash = "sha256:19407ced081d7e2e4b8d8c36aa57b7452e0283871c296e12d798852bc7d7f198", size = 2687030, upload-time = "2024-06-13T18:10:39.47Z" }, +] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129, upload-time = "2024-02-06T00:26:44.495Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468, upload-time = "2024-02-05T23:48:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411, upload-time = "2024-02-05T23:48:29.038Z" }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016, upload-time = "2024-02-05T23:48:54.098Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889, upload-time = "2024-02-05T23:49:25.361Z" }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746, upload-time = "2024-02-05T23:49:51.983Z" }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620, upload-time = "2024-02-05T23:50:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659, upload-time = "2024-02-05T23:50:35.834Z" }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905, upload-time = "2024-02-05T23:51:03.701Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pandas" +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/d9/ecf715f34c73ccb1d8ceb82fc01cd1028a65a5f6dbc57bfa6ea155119058/pandas-2.2.2.tar.gz", hash = "sha256:9e79019aba43cb4fda9e4d983f8e88ca0373adbb697ae9c6c43093218de28b54", size = 4398391, upload-time = "2024-04-10T19:45:48.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/2d/39600d073ea70b9cafdc51fab91d69c72b49dd92810f24cb5ac6631f387f/pandas-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:90c6fca2acf139569e74e8781709dccb6fe25940488755716d1d354d6bc58bce", size = 12551798, upload-time = "2024-04-10T19:44:10.36Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4b/0cd38e68ab690b9df8ef90cba625bf3f93b82d1c719703b8e1b333b2c72d/pandas-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7adfc142dac335d8c1e0dcbd37eb8617eac386596eb9e1a1b77791cf2498238", size = 11287392, upload-time = "2024-04-15T13:26:36.237Z" }, + { url = "https://files.pythonhosted.org/packages/01/c6/d3d2612aea9b9f28e79a30b864835dad8f542dcf474eee09afeee5d15d75/pandas-2.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4abfe0be0d7221be4f12552995e58723c7422c80a659da13ca382697de830c08", size = 15634823, upload-time = "2024-04-10T19:44:14.933Z" }, + { url = "https://files.pythonhosted.org/packages/89/1b/12521efcbc6058e2673583bb096c2b5046a9df39bd73eca392c1efed24e5/pandas-2.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8635c16bf3d99040fdf3ca3db669a7250ddf49c55dc4aa8fe0ae0fa8d6dcc1f0", size = 13032214, upload-time = "2024-04-10T19:44:19.013Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/303dba73f1c3a9ef067d23e5afbb6175aa25e8121be79be354dcc740921a/pandas-2.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:40ae1dffb3967a52203105a077415a86044a2bea011b5f321c6aa64b379a3f51", size = 16278302, upload-time = "2024-04-10T19:44:23.198Z" }, + { url = "https://files.pythonhosted.org/packages/ba/df/8ff7c5ed1cc4da8c6ab674dc8e4860a4310c3880df1283e01bac27a4333d/pandas-2.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8e5a0b00e1e56a842f922e7fae8ae4077aee4af0acb5ae3622bd4b4c30aedf99", size = 13892866, upload-time = "2024-04-10T19:44:27.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/a6/81d5dc9a612cf0c1810c2ebc4f2afddb900382276522b18d128213faeae3/pandas-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:ddf818e4e6c7c6f4f7c8a12709696d193976b591cc7dc50588d3d1a6b5dc8772", size = 11621592, upload-time = "2024-04-10T19:44:31.481Z" }, +] + +[[package]] +name = "parso" +version = "0.8.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, +] + +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "prompt-toolkit" +version = "3.0.52" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/96/06e01a7b38dce6fe1db213e061a4602dd6032a8a97ef6c1a862537732421/prompt_toolkit-3.0.52.tar.gz", hash = "sha256:28cde192929c8e7321de85de1ddbe736f1375148b02f2e17edd840042b1be855", size = 434198, upload-time = "2025-08-27T15:24:02.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/03/0d3ce49e2505ae70cf43bc5bb3033955d2fc9f932163e84dc0779cc47f48/prompt_toolkit-3.0.52-py3-none-any.whl", hash = "sha256:9aac639a3bbd33284347de5ad8d68ecc044b91a762dc39b7c21095fcd6a19955", size = 391431, upload-time = "2025-08-27T15:23:59.498Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762, upload-time = "2020-12-28T15:15:30.155Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993, upload-time = "2020-12-28T15:15:28.35Z" }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752, upload-time = "2024-07-21T12:58:21.801Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyreason" +version = "3.6.0" +source = { editable = "../" } +dependencies = [ + { name = "memory-profiler" }, + { name = "networkx" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pytest" }, + { name = "pyyaml" }, +] + +[package.metadata] +requires-dist = [ + { name = "memory-profiler" }, + { name = "networkx" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pytest" }, + { name = "pyyaml" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pytz" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, +] + +[[package]] +name = "pyzmq" +version = "27.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "implementation_name == 'pypy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/04/0b/3c9baedbdf613ecaa7aa07027780b8867f57b6293b6ee50de316c9f3222b/pyzmq-27.1.0.tar.gz", hash = "sha256:ac0765e3d44455adb6ddbf4417dcce460fc40a05978c08efdf2948072f6db540", size = 281750, upload-time = "2025-09-08T23:10:18.157Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/b9/52aa9ec2867528b54f1e60846728d8b4d84726630874fee3a91e66c7df81/pyzmq-27.1.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:508e23ec9bc44c0005c4946ea013d9317ae00ac67778bd47519fdf5a0e930ff4", size = 1329850, upload-time = "2025-09-08T23:07:26.274Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/5653e7b7425b169f994835a2b2abf9486264401fdef18df91ddae47ce2cc/pyzmq-27.1.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:507b6f430bdcf0ee48c0d30e734ea89ce5567fd7b8a0f0044a369c176aa44556", size = 906380, upload-time = "2025-09-08T23:07:29.78Z" }, + { url = "https://files.pythonhosted.org/packages/73/78/7d713284dbe022f6440e391bd1f3c48d9185673878034cfb3939cdf333b2/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf7b38f9fd7b81cb6d9391b2946382c8237fd814075c6aa9c3b746d53076023b", size = 666421, upload-time = "2025-09-08T23:07:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/30/76/8f099f9d6482450428b17c4d6b241281af7ce6a9de8149ca8c1c649f6792/pyzmq-27.1.0-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03ff0b279b40d687691a6217c12242ee71f0fba28bf8626ff50e3ef0f4410e1e", size = 854149, upload-time = "2025-09-08T23:07:33.17Z" }, + { url = "https://files.pythonhosted.org/packages/59/f0/37fbfff06c68016019043897e4c969ceab18bde46cd2aca89821fcf4fb2e/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:677e744fee605753eac48198b15a2124016c009a11056f93807000ab11ce6526", size = 1655070, upload-time = "2025-09-08T23:07:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/47/14/7254be73f7a8edc3587609554fcaa7bfd30649bf89cd260e4487ca70fdaa/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:dd2fec2b13137416a1c5648b7009499bcc8fea78154cd888855fa32514f3dad1", size = 2033441, upload-time = "2025-09-08T23:07:37.432Z" }, + { url = "https://files.pythonhosted.org/packages/22/dc/49f2be26c6f86f347e796a4d99b19167fc94503f0af3fd010ad262158822/pyzmq-27.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:08e90bb4b57603b84eab1d0ca05b3bbb10f60c1839dc471fc1c9e1507bef3386", size = 1891529, upload-time = "2025-09-08T23:07:39.047Z" }, + { url = "https://files.pythonhosted.org/packages/a3/3e/154fb963ae25be70c0064ce97776c937ecc7d8b0259f22858154a9999769/pyzmq-27.1.0-cp310-cp310-win32.whl", hash = "sha256:a5b42d7a0658b515319148875fcb782bbf118dd41c671b62dae33666c2213bda", size = 567276, upload-time = "2025-09-08T23:07:40.695Z" }, + { url = "https://files.pythonhosted.org/packages/62/b2/f4ab56c8c595abcb26b2be5fd9fa9e6899c1e5ad54964e93ae8bb35482be/pyzmq-27.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0bb87227430ee3aefcc0ade2088100e528d5d3298a0a715a64f3d04c60ba02f", size = 632208, upload-time = "2025-09-08T23:07:42.298Z" }, + { url = "https://files.pythonhosted.org/packages/3b/e3/be2cc7ab8332bdac0522fdb64c17b1b6241a795bee02e0196636ec5beb79/pyzmq-27.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:9a916f76c2ab8d045b19f2286851a38e9ac94ea91faf65bd64735924522a8b32", size = 559766, upload-time = "2025-09-08T23:07:43.869Z" }, + { url = "https://files.pythonhosted.org/packages/92/e7/038aab64a946d535901103da16b953c8c9cc9c961dadcbf3609ed6428d23/pyzmq-27.1.0-cp312-abi3-macosx_10_15_universal2.whl", hash = "sha256:452631b640340c928fa343801b0d07eb0c3789a5ffa843f6e1a9cee0ba4eb4fc", size = 1306279, upload-time = "2025-09-08T23:08:03.807Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5e/c3c49fdd0f535ef45eefcc16934648e9e59dace4a37ee88fc53f6cd8e641/pyzmq-27.1.0-cp312-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1c179799b118e554b66da67d88ed66cd37a169f1f23b5d9f0a231b4e8d44a113", size = 895645, upload-time = "2025-09-08T23:08:05.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/b0b2504cb4e903a74dcf1ebae157f9e20ebb6ea76095f6cfffea28c42ecd/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3837439b7f99e60312f0c926a6ad437b067356dc2bc2ec96eb395fd0fe804233", size = 652574, upload-time = "2025-09-08T23:08:06.828Z" }, + { url = "https://files.pythonhosted.org/packages/f8/9b/c108cdb55560eaf253f0cbdb61b29971e9fb34d9c3499b0e96e4e60ed8a5/pyzmq-27.1.0-cp312-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43ad9a73e3da1fab5b0e7e13402f0b2fb934ae1c876c51d0afff0e7c052eca31", size = 840995, upload-time = "2025-09-08T23:08:08.396Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bb/b79798ca177b9eb0825b4c9998c6af8cd2a7f15a6a1a4272c1d1a21d382f/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0de3028d69d4cdc475bfe47a6128eb38d8bc0e8f4d69646adfbcd840facbac28", size = 1642070, upload-time = "2025-09-08T23:08:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/9c/80/2df2e7977c4ede24c79ae39dcef3899bfc5f34d1ca7a5b24f182c9b7a9ca/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf44a7763aea9298c0aa7dbf859f87ed7012de8bda0f3977b6fb1d96745df856", size = 2021121, upload-time = "2025-09-08T23:08:11.907Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/2d45ad24f5f5ae7e8d01525eb76786fa7557136555cac7d929880519e33a/pyzmq-27.1.0-cp312-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f30f395a9e6fbca195400ce833c731e7b64c3919aa481af4d88c3759e0cb7496", size = 1878550, upload-time = "2025-09-08T23:08:13.513Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2f/104c0a3c778d7c2ab8190e9db4f62f0b6957b53c9d87db77c284b69f33ea/pyzmq-27.1.0-cp312-abi3-win32.whl", hash = "sha256:250e5436a4ba13885494412b3da5d518cd0d3a278a1ae640e113c073a5f88edd", size = 559184, upload-time = "2025-09-08T23:08:15.163Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7f/a21b20d577e4100c6a41795842028235998a643b1ad406a6d4163ea8f53e/pyzmq-27.1.0-cp312-abi3-win_amd64.whl", hash = "sha256:9ce490cf1d2ca2ad84733aa1d69ce6855372cb5ce9223802450c9b2a7cba0ccf", size = 619480, upload-time = "2025-09-08T23:08:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/78/c2/c012beae5f76b72f007a9e91ee9401cb88c51d0f83c6257a03e785c81cc2/pyzmq-27.1.0-cp312-abi3-win_arm64.whl", hash = "sha256:75a2f36223f0d535a0c919e23615fc85a1e23b71f40c7eb43d7b1dedb4d8f15f", size = 552993, upload-time = "2025-09-08T23:08:18.926Z" }, + { url = "https://files.pythonhosted.org/packages/f3/81/a65e71c1552f74dec9dff91d95bafb6e0d33338a8dfefbc88aa562a20c92/pyzmq-27.1.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c17e03cbc9312bee223864f1a2b13a99522e0dc9f7c5df0177cd45210ac286e6", size = 836266, upload-time = "2025-09-08T23:09:40.048Z" }, + { url = "https://files.pythonhosted.org/packages/58/ed/0202ca350f4f2b69faa95c6d931e3c05c3a397c184cacb84cb4f8f42f287/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f328d01128373cb6763823b2b4e7f73bdf767834268c565151eacb3b7a392f90", size = 800206, upload-time = "2025-09-08T23:09:41.902Z" }, + { url = "https://files.pythonhosted.org/packages/47/42/1ff831fa87fe8f0a840ddb399054ca0009605d820e2b44ea43114f5459f4/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1790386614232e1b3a40a958454bdd42c6d1811837b15ddbb052a032a43f62", size = 567747, upload-time = "2025-09-08T23:09:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/d1/db/5c4d6807434751e3f21231bee98109aa57b9b9b55e058e450d0aef59b70f/pyzmq-27.1.0-pp310-pypy310_pp73-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:448f9cb54eb0cee4732b46584f2710c8bc178b0e5371d9e4fc8125201e413a74", size = 747371, upload-time = "2025-09-08T23:09:45.575Z" }, + { url = "https://files.pythonhosted.org/packages/26/af/78ce193dbf03567eb8c0dc30e3df2b9e56f12a670bf7eb20f9fb532c7e8a/pyzmq-27.1.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:05b12f2d32112bf8c95ef2e74ec4f1d4beb01f8b5e703b38537f8849f92cb9ba", size = 544862, upload-time = "2025-09-08T23:09:47.448Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rpds-py" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/4ba3d0fb7297ebae71171822554abe48d7cab29c28b8f9f2c04b79988c05/rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00", size = 359751, upload-time = "2025-11-30T20:21:34.591Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7c/e4933565ef7f7a0818985d87c15d9d273f1a649afa6a52ea35ad011195ea/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6", size = 389696, upload-time = "2025-11-30T20:21:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/01/6271a2511ad0815f00f7ed4390cf2567bec1d4b1da39e2c27a41e6e3b4de/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7", size = 403136, upload-time = "2025-11-30T20:21:37.728Z" }, + { url = "https://files.pythonhosted.org/packages/55/64/c857eb7cd7541e9b4eee9d49c196e833128a55b89a9850a9c9ac33ccf897/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324", size = 524699, upload-time = "2025-11-30T20:21:38.92Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ed/94816543404078af9ab26159c44f9e98e20fe47e2126d5d32c9d9948d10a/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df", size = 412022, upload-time = "2025-11-30T20:21:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/707f6cf0066a6412aacc11d17920ea2e19e5b2f04081c64526eb35b5c6e7/rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3", size = 390522, upload-time = "2025-11-30T20:21:42.17Z" }, + { url = "https://files.pythonhosted.org/packages/13/4e/57a85fda37a229ff4226f8cbcf09f2a455d1ed20e802ce5b2b4a7f5ed053/rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221", size = 404579, upload-time = "2025-11-30T20:21:43.769Z" }, + { url = "https://files.pythonhosted.org/packages/f9/da/c9339293513ec680a721e0e16bf2bac3db6e5d7e922488de471308349bba/rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7", size = 421305, upload-time = "2025-11-30T20:21:44.994Z" }, + { url = "https://files.pythonhosted.org/packages/f9/be/522cb84751114f4ad9d822ff5a1aa3c98006341895d5f084779b99596e5c/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff", size = 572503, upload-time = "2025-11-30T20:21:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/a2/9b/de879f7e7ceddc973ea6e4629e9b380213a6938a249e94b0cdbcc325bb66/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7", size = 598322, upload-time = "2025-11-30T20:21:48.709Z" }, + { url = "https://files.pythonhosted.org/packages/48/ac/f01fc22efec3f37d8a914fc1b2fb9bcafd56a299edbe96406f3053edea5a/rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139", size = 560792, upload-time = "2025-11-30T20:21:50.024Z" }, + { url = "https://files.pythonhosted.org/packages/e2/da/4e2b19d0f131f35b6146425f846563d0ce036763e38913d917187307a671/rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464", size = 221901, upload-time = "2025-11-30T20:21:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/156d7a5cf4f78a7cc571465d8aec7a3c447c94f6749c5123f08438bcf7bc/rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169", size = 235823, upload-time = "2025-11-30T20:21:52.505Z" }, +] + +[[package]] +name = "setuptools" +version = "80.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/95/faf61eb8363f26aa7e1d762267a8d602a1b26d4f3a1e758e92cb3cb8b054/setuptools-80.10.2.tar.gz", hash = "sha256:8b0e9d10c784bf7d262c4e5ec5d4ec94127ce206e8738f29a437945fbc219b70", size = 1200343, upload-time = "2026-01-25T22:38:17.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/b8/f1f62a5e3c0ad2ff1d189590bfa4c46b4f3b6e49cef6f26c6ee4e575394d/setuptools-80.10.2-py3-none-any.whl", hash = "sha256:95b30ddfb717250edb492926c92b5221f7ef3fbcc2b07579bcd4a27da21d0173", size = 1064234, upload-time = "2026-01-25T22:38:15.216Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "srdatalog" +version = "0.2.0.dev0" +source = { url = "https://github.com/harp-lab/srdatalog-python/archive/a5ccfdae531c2f321f5746634eb97515def34b90.tar.gz" } +sdist = { hash = "sha256:265616f6e283f48af74d7611f5158c96b350be58671e67d3ee3fae001cb87d18" } + +[[package]] +name = "srdatalog-vulreasoner-demo" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "networkx" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pyreason" }, + { name = "setuptools" }, + { name = "srdatalog" }, +] + +[package.dev-dependencies] +gpu-benchmark = [ + { name = "cupy-cuda12x" }, +] +notebook = [ + { name = "ipykernel" }, + { name = "ipython" }, + { name = "jinja2" }, + { name = "matplotlib" }, + { name = "nbclient" }, + { name = "nbformat" }, +] + +[package.metadata] +requires-dist = [ + { name = "networkx", specifier = "==3.3" }, + { name = "numba", specifier = "==0.60.0" }, + { name = "numpy", specifier = "==1.26.4" }, + { name = "pandas", specifier = "==2.2.2" }, + { name = "pyreason", editable = "../" }, + { name = "setuptools", specifier = "<81" }, + { name = "srdatalog", url = "https://github.com/harp-lab/srdatalog-python/archive/a5ccfdae531c2f321f5746634eb97515def34b90.tar.gz" }, +] + +[package.metadata.requires-dev] +gpu-benchmark = [{ name = "cupy-cuda12x", specifier = "==13.3.0" }] +notebook = [ + { name = "ipykernel", specifier = "==6.29.5" }, + { name = "ipython", specifier = "==8.39.0" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "matplotlib", specifier = "==3.10.0" }, + { name = "nbclient", specifier = "==0.10.4" }, + { name = "nbformat", specifier = "==5.10.4" }, +] + +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707, upload-time = "2023-09-30T13:58:05.479Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521, upload-time = "2023-09-30T13:58:03.53Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tornado" +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, +] + +[[package]] +name = "traitlets" +version = "5.15.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/a9/a2584b8313b89f94869ddb3c4074617a691de1812a614d2d50e32ca5a7a6/traitlets-5.15.1.tar.gz", hash = "sha256:7b1c07854fe25acb39e009bae49f11b79ff6cbb2f27999104e9110e7a6b53722", size = 163344, upload-time = "2026-06-03T12:26:06.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/8d/1080ee4c231f361b6ce4470d556c8c435b67c7e0753aaa641497ee92f88b/traitlets-5.15.1-py3-none-any.whl", hash = "sha256:770a53705f84b81ac107e83a1b3328ff2dae16094d8fc3cfc004e4b22dfd8e92", size = 85858, upload-time = "2026-06-03T12:26:04.395Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "tzdata" +version = "2026.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, +] + +[[package]] +name = "wcwidth" +version = "0.8.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, +] diff --git a/minimal_vulreasoner/validate_minimal_parity.py b/minimal_vulreasoner/validate_minimal_parity.py new file mode 100644 index 00000000..3654b22d --- /dev/null +++ b/minimal_vulreasoner/validate_minimal_parity.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +'''Differentially validate the real minimal example against PyReason.''' + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from minimal_vulreasoner.example_config import WORKFLOW + +RESULT_PREFIX = "RESULT_JSON=" + + +def _pyreason_rows(checkout: str | None) -> dict[str, list[float]]: + if checkout: + root = Path(checkout).expanduser().resolve() + if not (root / "pyreason" / "__init__.py").exists(): + raise RuntimeError(f"PyReason checkout not found at {root}") + sys.path.insert(0, str(root)) + + import pyreason as pr + + from minimal_vulreasoner import run_minimal_reasoner as oracle + + oracle.configure_engine() + oracle.load_rules() + oracle.add_facts() + interpretation = pr.reason(timesteps=oracle.END_TIME) + history = interpretation.get_dict() + workflow_nodes = {node for node, _ in WORKFLOW} + rows = {} + for time_, components in history.items(): + for node, predicates in components.items(): + if node not in workflow_nodes or "analystAt" not in predicates: + continue + lower, upper = predicates["analystAt"] + if (lower, upper) != (0.0, 0.0): + rows[f"{node}@{time_}"] = [float(lower), float(upper)] + return dict(sorted(rows.items())) + + +def _srdatalog_result(args) -> dict[str, object]: + runner = Path(__file__).with_name("run_srdatalog_reasoner.py") + command = [ + sys.executable, + str(runner), + "--cache-base", + args.cache_base, + "--data-dir", + args.data_dir, + "--jobs", + str(args.jobs), + ] + if args.no_compile: + command.append("--no-compile") + completed = subprocess.run(command, text=True, capture_output=True, check=False) + if completed.returncode != 0: + raise RuntimeError( + f"SRDatalog runner failed ({completed.returncode})\n" + + completed.stdout + + completed.stderr + ) + print(completed.stdout, end="") + result_line = next( + (line for line in completed.stdout.splitlines() if line.startswith(RESULT_PREFIX)), + None, + ) + if result_line is None: + raise RuntimeError("SRDatalog runner did not emit RESULT_JSON") + return json.loads(result_line.removeprefix(RESULT_PREFIX)) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--pyreason-checkout") + parser.add_argument("--cache-base", default="./build") + parser.add_argument("--data-dir", default="./build/minimal_vulreasoner_graphml") + parser.add_argument("--jobs", type=int, default=8) + parser.add_argument("--no-compile", action="store_true") + args = parser.parse_args() + + pyreason_rows = _pyreason_rows(args.pyreason_checkout) + srdatalog = _srdatalog_result(args) + srdatalog_rows = srdatalog["analyst_rows"] + if srdatalog_rows != pyreason_rows: + print("FAIL: PyReason and SRDatalog temporal AnalystAt rows differ") + print("PYREASON=" + json.dumps(pyreason_rows, sort_keys=True)) + print("SRDATALOG=" + json.dumps(srdatalog_rows, sort_keys=True)) + raise SystemExit(1) + print("PASS: complete temporal AnalystAt rows match PyReason") + print("ANALYST_ROWS=" + json.dumps(pyreason_rows, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/minimal_vulreasoner/vulreasoner_srdatalog_semantics.ipynb b/minimal_vulreasoner/vulreasoner_srdatalog_semantics.ipynb new file mode 100644 index 00000000..25f3b8b7 --- /dev/null +++ b/minimal_vulreasoner/vulreasoner_srdatalog_semantics.ipynb @@ -0,0 +1,1438 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7f69ee44", + "metadata": {}, + "source": [ + "# VulReasoner on SRDatalog\n", + "\n", + "## Semantic gap, provenance-preserving lowering, exact parity, and scale\n", + "\n", + "This notebook explains and validates the interval-valued VulReasoner port in `minimal_vulreasoner/`. It is grounded in the checked-in PyReason rules, annotation callback, SRDatalog query, default `CWE_121_MVP2.graphml`, and the shared deterministic stress generator.\n", + "\n", + "The central result is that one PyReason rule hides **two different aggregates**. SRDatalog must preserve their grouping boundary:\n", + "\n", + "1. **within one rule and head key:** score each derivation witness, then select one witness by `ARG MAX(lower)`;\n", + "2. **across rules for the same `AnalystAt` key:** intersect the already-selected intervals.\n", + "\n", + "Collapsing these into one duplicate-handling operation changes the answer. The executable sections below exhibit the counterexample, show the two-rule lowering, compare canonical result bytes on the default graph, and report bounded scaling measurements.\n", + "\n", + "> Kernel: select **minimal_vulreasoner/.venv (Python 3.10.12)** after running `uv sync --project minimal_vulreasoner --frozen`. The first code cell rejects any other interpreter. PyReason's Numba fixed-point kernel does not compile under the checkout's Python 3.12 development environment. The notebook contains the verified 2026-07-16 snapshot so ordinary **Run All** is fast. Set `VULREASONER_NOTEBOOK_LIVE=1` before starting Jupyter to rerun PyReason and the cached CUDA artifact. Live PyReason subprocesses have a 120-second suite timeout." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "0c7f8189", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-17T16:23:18.871886Z", + "iopub.status.busy": "2026-07-17T16:23:18.871661Z", + "iopub.status.idle": "2026-07-17T16:23:19.287297Z", + "shell.execute_reply": "2026-07-17T16:23:19.286908Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [] + } + ], + "source": [ + "from __future__ import annotations\n", + "\n", + "import csv\n", + "import hashlib\n", + "import inspect\n", + "import json\n", + "import os\n", + "import subprocess\n", + "import sys\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import pandas as pd\n", + "from IPython.display import display\n", + "\n", + "def find_repo_root() -> Path:\n", + " for candidate in (Path.cwd(), *Path.cwd().parents):\n", + " if (candidate / 'minimal_vulreasoner').is_dir() and (candidate / 'pyreason').is_dir():\n", + " return candidate.resolve()\n", + " raise RuntimeError('Run this notebook from the PyReason fork checkout.')\n", + "\n", + "REPO = find_repo_root()\n", + "MINIMAL = REPO / 'minimal_vulreasoner'\n", + "EXPECTED_VENV = (MINIMAL / '.venv').resolve()\n", + "ACTIVE_PREFIX = Path(sys.prefix).resolve()\n", + "if ACTIVE_PREFIX != EXPECTED_VENV:\n", + " raise RuntimeError(\n", + " f'Wrong notebook kernel: {sys.executable}. Select srdatalog VulReasoner '\n", + " f'(minimal_vulreasoner/.venv, Python 3.10.12); '\n", + " f'expected prefix {EXPECTED_VENV}, got {ACTIVE_PREFIX}.'\n", + " )\n", + "for path in (REPO,):\n", + " if str(path) not in sys.path:\n", + " sys.path.insert(0, str(path))\n", + "\n", + "RULES_FILE = MINIMAL / 'rules' / 'analyst_rules.csv'\n", + "LIVE = os.environ.get('VULREASONER_NOTEBOOK_LIVE') == '1'\n", + "PYREASON_SUITE_TIMEOUT_SECONDS = 120\n", + "PYREASON_MAX_TRANSITIONS = 12_288\n", + "\n", + "print('repository :', REPO)\n", + "print('kernel :', sys.executable)\n", + "print('data mode :', 'LIVE engines' if LIVE else 'verified snapshot')\n", + "print('PyReason guard:', PYREASON_SUITE_TIMEOUT_SECONDS, 'seconds and', f'{PYREASON_MAX_TRANSITIONS:,}', 'transitions')" + ] + }, + { + "cell_type": "markdown", + "id": "f4fcc71c", + "metadata": {}, + "source": [ + "## 1. The semantic gap\n", + "\n", + "Classical positive Datalog gives each predicate a **set of tuples** and computes a least fixed point. Duplicate derivations of the same tuple are observationally identical. VulReasoner needs more structure:\n", + "\n", + "| Concern | Plain Datalog | VulReasoner requirement | SRDatalog representation |\n", + "|---|---|---|---|\n", + "| fact value | present / absent | confidence interval $[l,u]$ | two value columns `lower`, `upper` |\n", + "| logical time | absent or external | rule delay `<-1` | explicit `time` key plus `Successor(t,t1)` |\n", + "| body result | substitution | substitution plus supporting tuples and their intervals | join row / derivation witness |\n", + "| duplicates within one rule | collapse | choose the best supporting witness | rule-local candidate relation |\n", + "| duplicates from different rules | collapse | accumulate information by interval intersection | `AnalystAt` lattice merge |\n", + "\n", + "A satisfied body is both a relational **join result** and a **why-provenance witness**. Its tuple identities matter because the returned upper bound must come from the same witness whose lower bound won. Merely retaining a bag of lower and upper values loses that association.\n", + "\n", + "### Why this is not traditional semiring provenance\n", + "\n", + "In a traditional $K$-relation or provenance-polynomial interpretation, every input tuple $t$ receives a token $x_t$. A conjunctive derivation multiplies the tokens of its jointly used tuples, and alternative derivations of the same output are added:\n", + "\n", + "$$\\operatorname{Prov}(k)=\\bigoplus_{w\\in W_k}\\;\\bigotimes_{t\\in w}x_t.$$\n", + "\n", + "For the free provenance semiring $\\mathbb{N}[X]$, this expression preserves the alternative derivation monomials. More generally, a $K$-relation supplies **one global** $\\oplus$ for all duplicate derivations, regardless of which rule produced them. Query evaluation is compositional: it combines annotations with $\\otimes$ and $\\oplus$ rather than giving user code the grounded source tuples so it can inspect their values and identities.\n", + "\n", + "VulReasoner does something structurally different:\n", + "\n", + "$$\\operatorname{AnalystAt}(k)=\\bigsqcup_I{}_r\\; I\\!\\left(\\operatorname*{arg\\,max}_{w\\in W_{r,k}}(\\operatorname{lower}(I(w)),-\\operatorname{rank}(w))\\right).$$\n", + "\n", + "The alternatives are first **partitioned by source rule**. Within a partition, the callback compares numeric interval payloads, selects one witness, carries that witness's correlated upper bound, and discards the losing witnesses. Only afterward are winners from different rules combined by the different operation $\\sqcup_I$ (interval intersection). Consequently no single semiring addition can represent both duplicate cases: choosing `max` globally would wrongly make different rules compete, while using interval intersection globally would wrongly merge losing witnesses from the same rule.\n", + "\n", + "The connector rank retained by SRDatalog is therefore a selection/tie-break key, not a provenance variable, and the result does not contain a provenance polynomial or the set of all supporting derivations. The computation is **provenance-aware** because witness identity affects which interval survives; it is not traditional semiring-provenance processing. One could design a richer nested algebra carrying rule identities and witnesses, but that would be an explicit extension beyond ordinary $K$-relations, not merely 'use intervals as the provenance semiring.'\n", + "\n", + "The port is therefore best understood as *lattice-valued recursive Datalog with a rule-local grouped aggregate*, not as Boolean Datalog with two decorative columns." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "4878d23d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-17T16:23:19.288659Z", + "iopub.status.busy": "2026-07-17T16:23:19.288442Z", + "iopub.status.idle": "2026-07-17T16:23:19.295661Z", + "shell.execute_reply": "2026-07-17T16:23:19.295319Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
nameoriginal PyReason rule
0analyst-rule-1analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lcause):[0.1,1], hasLabel(CB2, Leffect):[0.1,1], can_cause(Lcause, Leffect):[0.1,1], step...
1analyst-rule-2analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lcontrib):[0.1,1], hasLabel(CB2, Lfault):[0.1,1], contributes_to(Lcontrib, Lfault):[0.1,1...
2analyst-rule-3analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lop):[0.1,1], hasLabel(CB2, Lderived):[0.1,1], derives(Lop, Lderived):[0.1,1], stepFrom(C...
3analyst-rule-4analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lunsafe):[0.1,1], hasLabel(CB2, Lsafe_concept):[0.1,1], unsafe_variant_of(Lunsafe, Lsafe_...
4analyst-rule-5analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lfault):[0.1,1], hasLabel(CB2, Lcwe):[0.1,1], manifestation_of(Lfault, Lcwe):[0.1,1], ste...
5analyst-rule-6analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lfunc):[0.1,1], hasLabel(CB2, Lop):[0.1,1], implements(Lfunc, Lop):[0.1,1], stepFrom(CB1,...
\n", + "
" + ], + "text/plain": [ + " name \\\n", + "0 analyst-rule-1 \n", + "1 analyst-rule-2 \n", + "2 analyst-rule-3 \n", + "3 analyst-rule-4 \n", + "4 analyst-rule-5 \n", + "5 analyst-rule-6 \n", + "\n", + " original PyReason rule \n", + "0 analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lcause):[0.1,1], hasLabel(CB2, Leffect):[0.1,1], can_cause(Lcause, Leffect):[0.1,1], step... \n", + "1 analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lcontrib):[0.1,1], hasLabel(CB2, Lfault):[0.1,1], contributes_to(Lcontrib, Lfault):[0.1,1... \n", + "2 analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lop):[0.1,1], hasLabel(CB2, Lderived):[0.1,1], derives(Lop, Lderived):[0.1,1], stepFrom(C... \n", + "3 analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lunsafe):[0.1,1], hasLabel(CB2, Lsafe_concept):[0.1,1], unsafe_variant_of(Lunsafe, Lsafe_... \n", + "4 analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lfault):[0.1,1], hasLabel(CB2, Lcwe):[0.1,1], manifestation_of(Lfault, Lcwe):[0.1,1], ste... \n", + "5 analystAt(CB2):paired_minimum_bounds_ann_fn <-1 analystAt(CB1):[0.25,1], hasLabel(CB1, Lfunc):[0.1,1], hasLabel(CB2, Lop):[0.1,1], implements(Lfunc, Lop):[0.1,1], stepFrom(CB1,... " + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "with RULES_FILE.open(newline='') as handle:\n", + " original_rules = list(csv.DictReader(handle))\n", + "\n", + "original_df = pd.DataFrame(\n", + " {\n", + " 'name': row['name'],\n", + " 'original PyReason rule': row['rule_text'],\n", + " }\n", + " for row in original_rules\n", + ")\n", + "pd.set_option('display.max_colwidth', 180)\n", + "display(original_df)\n", + "assert len(original_df) == 6" + ] + }, + { + "cell_type": "markdown", + "id": "8262cbfe", + "metadata": {}, + "source": [ + "## 2. What the PyReason annotation callback actually computes\n", + "\n", + "For a fixed rule $r$, output key $k=(CB2,t+1)$, and witness set $W_{r,k}$, the callback implements:\n", + "\n", + "$$I(w)=[\\min(l_c,l_e,l_d),\\;\\min(u_c,u_e,u_d)]$$\n", + "\n", + "$$w^*_{r,k}=\\operatorname*{arg\\,max}_{w\\in W_{r,k}}(\\operatorname{lower}(I(w)),-\\operatorname{rank}(w)),\\qquad C_r(k)=I(w^*_{r,k}).$$\n", + "\n", + "Here $c$, $e$, and $d$ are the matched cause-label, effect-label, and connector tuples. The componentwise `min` is the interval extension of a Gödel-style conjunction used by this application; it is **not** interval intersection. `rank` makes PyReason's first-witness tie behavior deterministic and is not evidence strength.\n", + "\n", + "The six-argument PyReason callback is unusual because `annotations[i]` alone is a flattened per-clause collection. `qualified_edges`, `clause_labels`, and `clause_variables` recover which `hasLabel` facts belong to each connector pair. This is limited provenance-aware selection: it remembers enough tuple identity to select correctly, but it is not full semiring provenance and does not retain every derivation polynomial.\n", + "\n", + "The next cell prints the exact callback used by the example." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "1f9467e3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-17T16:23:19.296845Z", + "iopub.status.busy": "2026-07-17T16:23:19.296697Z", + "iopub.status.idle": "2026-07-17T16:23:19.383413Z", + "shell.execute_reply": "2026-07-17T16:23:19.383039Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "@numba.njit\n", + "def paired_minimum_bounds_ann_fn(\n", + " annotations, weights, qualified_nodes, qualified_edges, clause_labels, clause_variables\n", + "):\n", + " head_var_x = \"CB1\"\n", + " head_var_y = \"CB2\"\n", + "\n", + " driver_idx = -1\n", + " has_label_cb1_idx = -1\n", + " has_label_cb2_idx = -1\n", + "\n", + " for i in range(len(clause_labels)):\n", + " name = clause_labels[i].value\n", + " cv = clause_variables[i]\n", + " if name == \"hasLabel\" and len(cv) == 2:\n", + " if cv[0] == head_var_x:\n", + " has_label_cb1_idx = i\n", + " elif cv[0] == head_var_y:\n", + " has_label_cb2_idx = i\n", + " elif name != \"analystAt\" and name != \"stepFrom\":\n", + " if (\n", + " len(cv) == 2\n", + " and cv[0] != head_var_x and cv[0] != head_var_y\n", + " and cv[1] != head_var_x and cv[1] != head_var_y\n", + " ):\n", + " driver_idx = i\n", + "\n", + " if driver_idx < 0 or has_label_cb1_idx < 0 or has_label_cb2_idx < 0:\n", + " return 0.0, 1.0\n", + "\n", + " best_lower = 0.0\n", + " best_upper = 0.0\n", + " found_any = False\n", + "\n", + " for ci in range(len(qualified_edges[driver_idx])):\n", + " x_val = qualified_edges[driver_idx][ci][0]\n", + " y_val = qualified_edges[driver_idx][ci][1]\n", + " d_lower = annotations[driver_idx][ci].lower\n", + " d_upper = annotations[driver_idx][ci].upper\n", + "\n", + " x_lower = -1.0\n", + " x_upper = -1.0\n", + " for k in range(len(qualified_edges[has_label_cb1_idx])):\n", + " if qualified_edges[has_label_cb1_idx][k][1] == x_val:\n", + " x_lower = annotations[has_label_cb1_idx][k].lower\n", + " x_upper = annotations[has_label_cb1_idx][k].upper\n", + " break\n", + " if x_lower < 0.0:\n", + " continue\n", + "\n", + " y_lower = -1.0\n", + " y_upper = -1.0\n", + " for k in range(len(qualified_edges[has_label_cb2_idx])):\n", + " if qualified_edges[has_label_cb2_idx][k][1] == y_val:\n", + " y_lower = annotations[has_label_cb2_idx][k].lower\n", + " y_upper = annotations[has_label_cb2_idx][k].upper\n", + " break\n", + " if y_lower < 0.0:\n", + " continue\n", + "\n", + " pair_lower = min(min(x_lower, y_lower), d_lower)\n", + " pair_upper = min(min(x_upper, y_upper), d_upper)\n", + "\n", + " if not found_any or pair_lower > best_lower:\n", + " best_lower = pair_lower\n", + " best_upper = pair_upper\n", + " found_any = True\n", + "\n", + " if not found_any:\n", + " return 0.0, 1.0\n", + " lower = min(best_lower, 1.0)\n", + " upper = min(best_upper, 1.0)\n", + " if lower > upper:\n", + " return 0.0, 1.0\n", + " return lower, upper\n", + "\n" + ] + } + ], + "source": [ + "from minimal_vulreasoner.annotation_fn import paired_minimum_bounds_ann_fn\n", + "\n", + "print(inspect.getsource(paired_minimum_bounds_ann_fn.py_func))" + ] + }, + { + "cell_type": "markdown", + "id": "7d8df29c", + "metadata": {}, + "source": [ + "### The second aggregate is different\n", + "\n", + "Once each rule has selected its winner, distinct rules may still derive the same `AnalystAt(node,time)` key. PyReason's world update accumulates information using interval intersection:\n", + "\n", + "$$[l_1,u_1]\\sqcup_I[l_2,u_2]=[\\max(l_1,l_2),\\min(u_1,u_2)].$$\n", + "\n", + "Under the information order $[l,u]\\sqsubseteq_I[l',u']$ iff $l\\le l'$ and $u\\ge u'$, a smaller interval is more informative and the operation above is the join. This is the operation used for recursive `AnalystAt` state. It is not the within-rule `ARG MAX`, and applying it to losing witnesses is incorrect." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "a94de053", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-17T16:23:19.384684Z", + "iopub.status.busy": "2026-07-17T16:23:19.384558Z", + "iopub.status.idle": "2026-07-17T16:23:19.396772Z", + "shell.execute_reply": "2026-07-17T16:23:19.396438Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
ruleheadranklower partsupper partswitness interval
0r1AnalystAt(b2,2)7(0.9, 0.85, 0.8)(0.98, 0.97, 0.95)(0.8, 0.95)
1r1AnalystAt(b2,2)9(0.7, 0.65, 0.6)(0.88, 0.84, 0.82)(0.6, 0.82)
2r2AnalystAt(b2,2)4(0.8, 0.75, 0.7)(0.92, 0.9, 0.85)(0.7, 0.85)
\n", + "
" + ], + "text/plain": [ + " rule head rank lower parts upper parts \\\n", + "0 r1 AnalystAt(b2,2) 7 (0.9, 0.85, 0.8) (0.98, 0.97, 0.95) \n", + "1 r1 AnalystAt(b2,2) 9 (0.7, 0.65, 0.6) (0.88, 0.84, 0.82) \n", + "2 r2 AnalystAt(b2,2) 4 (0.8, 0.75, 0.7) (0.92, 0.9, 0.85) \n", + "\n", + " witness interval \n", + "0 (0.8, 0.95) \n", + "1 (0.6, 0.82) \n", + "2 (0.7, 0.85) " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
computationinterval
0correct r1: select one witness(0.8, 0.95)
1wrong r1: intersect both witnesses(0.8, 0.82)
2correct final: intersect rule winners(0.8, 0.85)
3wrong final: one global ARG MAX(0.8, 0.95)
\n", + "
" + ], + "text/plain": [ + " computation interval\n", + "0 correct r1: select one witness (0.8, 0.95)\n", + "1 wrong r1: intersect both witnesses (0.8, 0.82)\n", + "2 correct final: intersect rule winners (0.8, 0.85)\n", + "3 wrong final: one global ARG MAX (0.8, 0.95)" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "def score(lower_parts, upper_parts):\n", + " return min(lower_parts), min(upper_parts)\n", + "\n", + "def information_join(intervals):\n", + " return max(i[0] for i in intervals), min(i[1] for i in intervals)\n", + "\n", + "witnesses = [\n", + " {'rule': 'r1', 'head': 'AnalystAt(b2,2)', 'rank': 7, 'lower parts': (0.90, 0.85, 0.80), 'upper parts': (0.98, 0.97, 0.95)},\n", + " {'rule': 'r1', 'head': 'AnalystAt(b2,2)', 'rank': 9, 'lower parts': (0.70, 0.65, 0.60), 'upper parts': (0.88, 0.84, 0.82)},\n", + " {'rule': 'r2', 'head': 'AnalystAt(b2,2)', 'rank': 4, 'lower parts': (0.80, 0.75, 0.70), 'upper parts': (0.92, 0.90, 0.85)},\n", + "]\n", + "for witness in witnesses:\n", + " witness['witness interval'] = score(witness['lower parts'], witness['upper parts'])\n", + "display(pd.DataFrame(witnesses))\n", + "\n", + "r1_rows = [w for w in witnesses if w['rule'] == 'r1']\n", + "r1_winner = max(r1_rows, key=lambda w: (w['witness interval'][0], -w['rank']))['witness interval']\n", + "r2_winner = witnesses[2]['witness interval']\n", + "wrong_direct_merge = information_join([w['witness interval'] for w in r1_rows])\n", + "correct_final = information_join([r1_winner, r2_winner])\n", + "wrong_global_argmax = max((r1_winner, r2_winner), key=lambda i: i[0])\n", + "\n", + "comparison = pd.DataFrame([\n", + " ('correct r1: select one witness', r1_winner),\n", + " ('wrong r1: intersect both witnesses', wrong_direct_merge),\n", + " ('correct final: intersect rule winners', correct_final),\n", + " ('wrong final: one global ARG MAX', wrong_global_argmax),\n", + "], columns=['computation', 'interval'])\n", + "display(comparison)\n", + "assert r1_winner == (0.80, 0.95)\n", + "assert wrong_direct_merge == (0.80, 0.82)\n", + "assert correct_final == (0.80, 0.85)\n", + "assert wrong_global_argmax == (0.80, 0.95)" + ] + }, + { + "cell_type": "markdown", + "id": "e6d42e26", + "metadata": {}, + "source": [ + "## 3. Why every PyReason rule becomes two SRDatalog rules\n", + "\n", + "For `analyst-rule-1`, PyReason presents one surface rule:\n", + "\n", + "```text\n", + "analystAt(CB2):paired_minimum_bounds_ann_fn <-1\n", + " analystAt(CB1):[0.25,1],\n", + " hasLabel(CB1,Lcause):[0.1,1],\n", + " hasLabel(CB2,Leffect):[0.1,1],\n", + " can_cause(Lcause,Leffect):[0.1,1],\n", + " stepFrom(CB1,CB2)\n", + "```\n", + "\n", + "Its semantics is made explicit as two rules (shown schematically with interval payloads):\n", + "\n", + "```text\n", + "CanCauseCandidate(CB2,t1,rank,l,u) :-\n", + " AnalystAt(CB1,t,la,ua), HasLabel(CB1,Lcause,lc,uc),\n", + " HasLabel(CB2,Leffect,le,ue),\n", + " CanCause(Lcause,Leffect,rank,ld,ud),\n", + " StepFrom(CB1,CB2,t), Successor(t,t1),\n", + " thresholds_hold, l=min(lc,le,ld), u=min(uc,ue,ud).\n", + "\n", + "AnalystAt(CB2,t1,l,u) :- CanCauseCandidate(CB2,t1,rank,l,u).\n", + "```\n", + "\n", + "`CanCauseCandidate` is functional by key `(CB2,t1)` and performs `max-lower-select` over `(rank,l,u)`. Its delta contains only the selected winner. The promotion rule is therefore only a projection. Insertion into functional `AnalystAt`, keyed by `(CB2,t1)`, then performs `interval-intersection`.\n", + "\n", + "The **rule identity is part of the grouping context**. Separate candidate relations ensure that two witnesses from the same rule compete, while winners from different rules intersect. A single candidate relation keyed only by the head would wrongly make different rules compete; direct insertion into `AnalystAt` would wrongly intersect losing witnesses. A future fused head-aggregate may remove the temporary materialization, but it cannot remove this semantic boundary." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "9dda3ab3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-17T16:23:19.397985Z", + "iopub.status.busy": "2026-07-17T16:23:19.397844Z", + "iopub.status.idle": "2026-07-17T16:23:19.457806Z", + "shell.execute_reply": "2026-07-17T16:23:19.457455Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
relationaritykey columnsvalue columnsduplicate operation
0AnalystAt4(0, 1)(2, 3)interval-intersection
1CanCauseCandidate5(0, 1)(2, 3, 4)max-lower-select
2ContributesToCandidate5(0, 1)(2, 3, 4)max-lower-select
3DerivesCandidate5(0, 1)(2, 3, 4)max-lower-select
4UnsafeVariantOfCandidate5(0, 1)(2, 3, 4)max-lower-select
5ManifestationOfCandidate5(0, 1)(2, 3, 4)max-lower-select
6ImplementsCandidate5(0, 1)(2, 3, 4)max-lower-select
\n", + "
" + ], + "text/plain": [ + " relation arity key columns value columns \\\n", + "0 AnalystAt 4 (0, 1) (2, 3) \n", + "1 CanCauseCandidate 5 (0, 1) (2, 3, 4) \n", + "2 ContributesToCandidate 5 (0, 1) (2, 3, 4) \n", + "3 DerivesCandidate 5 (0, 1) (2, 3, 4) \n", + "4 UnsafeVariantOfCandidate 5 (0, 1) (2, 3, 4) \n", + "5 ManifestationOfCandidate 5 (0, 1) (2, 3, 4) \n", + "6 ImplementsCandidate 5 (0, 1) (2, 3, 4) \n", + "\n", + " duplicate operation \n", + "0 interval-intersection \n", + "1 max-lower-select \n", + "2 max-lower-select \n", + "3 max-lower-select \n", + "4 max-lower-select \n", + "5 max-lower-select \n", + "6 max-lower-select " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
PyReason ruleconnectorcandidate relationSRDatalog join ruleSRDatalog projection rule
0analyst-rule-1can_causeCanCauseCandidateAnalystCanCausePromoteCanCauseCandidate
1analyst-rule-2contributes_toContributesToCandidateAnalystContributesToPromoteContributesToCandidate
2analyst-rule-3derivesDerivesCandidateAnalystDerivesPromoteDerivesCandidate
3analyst-rule-4unsafe_variant_ofUnsafeVariantOfCandidateAnalystUnsafeVariantOfPromoteUnsafeVariantOfCandidate
4analyst-rule-5manifestation_ofManifestationOfCandidateAnalystManifestationOfPromoteManifestationOfCandidate
5analyst-rule-6implementsImplementsCandidateAnalystImplementsPromoteImplementsCandidate
\n", + "
" + ], + "text/plain": [ + " PyReason rule connector candidate relation \\\n", + "0 analyst-rule-1 can_cause CanCauseCandidate \n", + "1 analyst-rule-2 contributes_to ContributesToCandidate \n", + "2 analyst-rule-3 derives DerivesCandidate \n", + "3 analyst-rule-4 unsafe_variant_of UnsafeVariantOfCandidate \n", + "4 analyst-rule-5 manifestation_of ManifestationOfCandidate \n", + "5 analyst-rule-6 implements ImplementsCandidate \n", + "\n", + " SRDatalog join rule SRDatalog projection rule \n", + "0 AnalystCanCause PromoteCanCauseCandidate \n", + "1 AnalystContributesTo PromoteContributesToCandidate \n", + "2 AnalystDerives PromoteDerivesCandidate \n", + "3 AnalystUnsafeVariantOf PromoteUnsafeVariantOfCandidate \n", + "4 AnalystManifestationOf PromoteManifestationOfCandidate \n", + "5 AnalystImplements PromoteImplementsCandidate " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Actual program rule order:\n", + " AnalystSeed\n", + " AnalystCanCause\n", + " PromoteCanCauseCandidate\n", + " AnalystContributesTo\n", + " PromoteContributesToCandidate\n", + " AnalystDerives\n", + " PromoteDerivesCandidate\n", + " AnalystUnsafeVariantOf\n", + " PromoteUnsafeVariantOfCandidate\n", + " AnalystManifestationOf\n", + " PromoteManifestationOfCandidate\n", + " AnalystImplements\n", + " PromoteImplementsCandidate\n" + ] + } + ], + "source": [ + "from minimal_vulreasoner.srdatalog_query import RULE_SPECS, build_analyst_program\n", + "\n", + "program = build_analyst_program()\n", + "value_relations = []\n", + "for relation in program.relations:\n", + " if relation.name == 'AnalystAt' or relation.name.endswith('Candidate'):\n", + " value_relations.append({\n", + " 'relation': relation.name,\n", + " 'arity': relation.arity,\n", + " 'key columns': relation.value_spec.key_columns,\n", + " 'value columns': relation.value_spec.value_columns,\n", + " 'duplicate operation': relation.value_spec.join.value,\n", + " })\n", + "display(pd.DataFrame(value_relations))\n", + "\n", + "lowering_map = pd.DataFrame([\n", + " {\n", + " 'PyReason rule': spec.name,\n", + " 'connector': spec.connector_predicate,\n", + " 'candidate relation': f'{spec.relation_name}Candidate',\n", + " 'SRDatalog join rule': f'Analyst{spec.relation_name}',\n", + " 'SRDatalog projection rule': f'Promote{spec.relation_name}Candidate',\n", + " }\n", + " for spec in RULE_SPECS\n", + "])\n", + "display(lowering_map)\n", + "print('Actual program rule order:')\n", + "print(' ' + '\\n '.join(rule.name for rule in program.rules))\n", + "assert len(program.rules) == 1 + 2 * len(RULE_SPECS)" + ] + }, + { + "cell_type": "markdown", + "id": "69a2175a", + "metadata": {}, + "source": [ + "## 4. Physical interval encoding\n", + "\n", + "Every annotated logical tuple carries its range as **two extra columns**. Examples:\n", + "\n", + "- `HasLabel(block,label,lower_bits,upper_bits)`\n", + "- `CanCause(cause,effect,rank,lower_bits,upper_bits)`\n", + "- `AnalystAt(node,time,lower_bits,upper_bits)`\n", + "- `CanCauseCandidate(node,time,rank,lower_bits,upper_bits)`\n", + "\n", + "Bounds are bit-cast IEEE-754 binary32 words. All application values are in `[0,1]`, so unsigned word order is the same as float order. This lets the generated CUDA path use integer `min`/`max` without changing the logical order, while decoding remains exact.\n", + "\n", + "The two computations over these columns remain distinct: candidate maintenance compares lower values and carries the entire winning `(rank,lower,upper)` payload; `AnalystAt` maintenance computes `(max lower, min upper)`." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e738e07f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-17T16:23:19.459080Z", + "iopub.status.busy": "2026-07-17T16:23:19.458927Z", + "iopub.status.idle": "2026-07-17T16:23:19.464564Z", + "shell.execute_reply": "2026-07-17T16:23:19.464236Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
float bounduint32 worddecoded float32
00.0000.00
10.1010368319490.10
20.2510485760000.25
30.6010586423300.60
40.8010619977730.80
51.0010653532161.00
\n", + "
" + ], + "text/plain": [ + " float bound uint32 word decoded float32\n", + "0 0.00 0 0.00\n", + "1 0.10 1036831949 0.10\n", + "2 0.25 1048576000 0.25\n", + "3 0.60 1058642330 0.60\n", + "4 0.80 1061997773 0.80\n", + "5 1.00 1065353216 1.00" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "from srdatalog import float32_to_u32, u32_to_float32\n", + "\n", + "values = [0.0, 0.1, 0.25, 0.6, 0.8, 1.0]\n", + "encoding_df = pd.DataFrame({\n", + " 'float bound': values,\n", + " 'uint32 word': [float32_to_u32(value) for value in values],\n", + " 'decoded float32': [u32_to_float32(float32_to_u32(value)) for value in values],\n", + "})\n", + "display(encoding_df)\n", + "assert encoding_df['uint32 word'].tolist() == sorted(encoding_df['uint32 word'])\n", + "assert all(float32_to_u32(decoded) == bits for decoded, bits in zip(encoding_df['decoded float32'], encoding_df['uint32 word']))" + ] + }, + { + "cell_type": "markdown", + "id": "8936395e", + "metadata": {}, + "source": [ + "## 5. Default GraphML: canonical bytes match exactly\n", + "\n", + "The default checked-in input has 188 graph nodes, 403 graph edges, and 138 connector attributes used by the six rules. The expected temporal chain is:\n", + "\n", + "```text\n", + "b1@0, b1@1 -> b2@2 -> b3@3 -> b4@4\n", + "```\n", + "\n", + "For engine-independent comparison, each result is serialized in sorted order as `node@time,lower_float32_bits,upper_float32_bits\\n`. This catches row-set, key, interval, and floating-point representation differences—not merely approximate numeric equality. In live mode, `validate_minimal_parity.py` runs the real PyReason oracle and cached SRDatalog CUDA artifact before this serialization." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "e8e52a3c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-17T16:23:19.465776Z", + "iopub.status.busy": "2026-07-17T16:23:19.465636Z", + "iopub.status.idle": "2026-07-17T16:24:38.180919Z", + "shell.execute_reply": "2026-07-17T16:24:38.180046Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
enginerowsbytessha256
0PyReason5135ae3d94e59f1417f1f7ab264f4bf5146e9e511a4b35c1a351bcc24cb4039d4b2d
1SRDatalog5135ae3d94e59f1417f1f7ab264f4bf5146e9e511a4b35c1a351bcc24cb4039d4b2d
\n", + "
" + ], + "text/plain": [ + " engine rows bytes \\\n", + "0 PyReason 5 135 \n", + "1 SRDatalog 5 135 \n", + "\n", + " sha256 \n", + "0 ae3d94e59f1417f1f7ab264f4bf5146e9e511a4b35c1a351bcc24cb4039d4b2d \n", + "1 ae3d94e59f1417f1f7ab264f4bf5146e9e511a4b35c1a351bcc24cb4039d4b2d " + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "mode: live PyReason + live cached SRDatalog\n", + "input: {'graph_nodes': 188, 'graph_edges': 403, 'selected_connector_facts': 138}\n", + "byte-for-byte equal: PASS\n", + "canonical SHA-256: ae3d94e59f1417f1f7ab264f4bf5146e9e511a4b35c1a351bcc24cb4039d4b2d\n", + "\n", + "Canonical result bytes:\n", + "b1@0,1065353216,1065353216\n", + "b1@1,1065353216,1065353216\n", + "b2@2,1065353216,1065353216\n", + "b3@3,1065353216,1065353216\n", + "b4@4,1065353216,1065353216\n", + "\n" + ] + } + ], + "source": [ + "VERIFIED_ROWS = {\n", + " 'b1@0': [1.0, 1.0],\n", + " 'b1@1': [1.0, 1.0],\n", + " 'b2@2': [1.0, 1.0],\n", + " 'b3@3': [1.0, 1.0],\n", + " 'b4@4': [1.0, 1.0],\n", + "}\n", + "\n", + "def prefixed_json(stdout: str, prefix: str):\n", + " line = next(line for line in stdout.splitlines() if line.startswith(prefix))\n", + " return json.loads(line.removeprefix(prefix))\n", + "\n", + "if LIVE:\n", + " env = {**os.environ, 'PYTHONPATH': f\"{REPO / 'src'}:{REPO}\", 'PYTHONHASHSEED': '0'}\n", + " completed = subprocess.run(\n", + " [sys.executable, str(MINIMAL / 'validate_minimal_parity.py'), '--no-compile'],\n", + " cwd=REPO, env=env, text=True, capture_output=True,\n", + " timeout=PYREASON_SUITE_TIMEOUT_SECONDS + 60, check=False,\n", + " )\n", + " if completed.returncode != 0:\n", + " raise RuntimeError(completed.stdout + completed.stderr)\n", + " srdatalog_result = prefixed_json(completed.stdout, 'RESULT_JSON=')\n", + " pyreason_rows = prefixed_json(completed.stdout, 'ANALYST_ROWS=')\n", + " srdatalog_rows = srdatalog_result['analyst_rows']\n", + " parity_mode = 'live PyReason + live cached SRDatalog'\n", + " graph_summary = {key: srdatalog_result[key] for key in ('graph_nodes', 'graph_edges', 'selected_connector_facts')}\n", + "else:\n", + " pyreason_rows = dict(VERIFIED_ROWS)\n", + " srdatalog_rows = dict(VERIFIED_ROWS)\n", + " parity_mode = 'verified 2026-07-16 snapshot'\n", + " graph_summary = {'graph_nodes': 188, 'graph_edges': 403, 'selected_connector_facts': 138}\n", + "\n", + "def canonical_result_bytes(rows):\n", + " return b''.join(\n", + " f'{key},{float32_to_u32(bounds[0])},{float32_to_u32(bounds[1])}\\n'.encode()\n", + " for key, bounds in sorted(rows.items())\n", + " )\n", + "\n", + "pyreason_bytes = canonical_result_bytes(pyreason_rows)\n", + "srdatalog_bytes = canonical_result_bytes(srdatalog_rows)\n", + "assert pyreason_bytes == srdatalog_bytes\n", + "digest = hashlib.sha256(pyreason_bytes).hexdigest()\n", + "\n", + "display(pd.DataFrame([\n", + " {'engine': 'PyReason', 'rows': len(pyreason_rows), 'bytes': len(pyreason_bytes), 'sha256': hashlib.sha256(pyreason_bytes).hexdigest()},\n", + " {'engine': 'SRDatalog', 'rows': len(srdatalog_rows), 'bytes': len(srdatalog_bytes), 'sha256': hashlib.sha256(srdatalog_bytes).hexdigest()},\n", + "]))\n", + "print('mode:', parity_mode)\n", + "print('input:', graph_summary)\n", + "print('byte-for-byte equal: PASS')\n", + "print('canonical SHA-256:', digest)\n", + "print('\\nCanonical result bytes:\\n' + pyreason_bytes.decode())\n", + "assert digest == 'ae3d94e59f1417f1f7ab264f4bf5146e9e511a4b35c1a351bcc24cb4039d4b2d'" + ] + }, + { + "cell_type": "markdown", + "id": "45e0b880", + "metadata": {}, + "source": [ + "## 6. Scaling experiment with a bounded PyReason oracle\n", + "\n", + "The stress generator creates layered workflows. `transitions = depth × width × fanout`; every transition has a timed `StepFrom`, two labeled endpoints, and one of the six connector predicates. Both engines consume the same logical workload, and SHA-256 covers every final target interval.\n", + "\n", + "Measurement policy:\n", + "\n", + "- PyReason is warmed once in one process; the table uses `reason_seconds` only.\n", + "- SRDatalog runs a 1×1×1 warmup first; the table uses cached GPU `run_seconds` only.\n", + "- parsing/setup, data loading, result copying, and one-time CUDA compilation are reported outside the compared reasoning time;\n", + "- the PyReason suite has a **120 s subprocess timeout** and a **12,288-transition size guard**; the 32,768-transition workload is intentionally GPU-only after the preceding PyReason case already takes about 29 s;\n", + "- exact digest equality, not timing, establishes correctness on every common case.\n", + "\n", + "These are single-run measurements on an NVIDIA RTX 6000 Ada. They demonstrate the scaling shape; they are not a statistically rigorous microbenchmark." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "a4491d83", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-17T16:24:38.183644Z", + "iopub.status.busy": "2026-07-17T16:24:38.183351Z", + "iopub.status.idle": "2026-07-17T16:26:25.878983Z", + "shell.execute_reply": "2026-07-17T16:26:25.878197Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
 depth×width×fanouttransitionsPyReason reason (ms)SRDatalog GPU run (ms)speedupdigest parity
04×4×2328.378.701.0×exact
18×32×41024353.7817.7320.0×exact
212×128×81228831,657.4933.12955.8×exact
316×256×832768—44.55—not run (size guard)
\n" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "benchmark mode: live\n", + "PyReason status: live; suite completed within timeout\n" + ] + } + ], + "source": [ + "VERIFIED_GPU = [\n", + " {'depth': 4, 'width': 4, 'fanout': 2, 'connectors': 32, 'run_seconds': 0.00863513299555052, 'target_bounds_sha256': 'bee00d04001d58bc5c97f379515c4cf334641ca98094f9ad942008cf183382e8'},\n", + " {'depth': 8, 'width': 32, 'fanout': 4, 'connectors': 1024, 'run_seconds': 0.01766743799089454, 'target_bounds_sha256': 'd91feeadb3901afa7a98bb86b863a3e075e59e23c59ca182d685fde0de23dab9'},\n", + " {'depth': 12, 'width': 128, 'fanout': 8, 'connectors': 12288, 'run_seconds': 0.03306771999632474, 'target_bounds_sha256': '2216633a28e871f9ddd1b8957f3097e826462315a73ebe9ab84d9f44394ca3f7'},\n", + " {'depth': 16, 'width': 256, 'fanout': 8, 'connectors': 32768, 'run_seconds': 0.04464685800485313, 'target_bounds_sha256': '277fcd77af443398d4e46e19481e58f0dc92ce47c1a4ff286dfb08c8a7703bc5'},\n", + "]\n", + "VERIFIED_PYREASON = [\n", + " {'depth': 4, 'width': 4, 'fanout': 2, 'connectors': 32, 'reason_seconds': 0.008218295988626778, 'target_bounds_sha256': 'bee00d04001d58bc5c97f379515c4cf334641ca98094f9ad942008cf183382e8'},\n", + " {'depth': 8, 'width': 32, 'fanout': 4, 'connectors': 1024, 'reason_seconds': 0.35287209700618405, 'target_bounds_sha256': 'd91feeadb3901afa7a98bb86b863a3e075e59e23c59ca182d685fde0de23dab9'},\n", + " {'depth': 12, 'width': 128, 'fanout': 8, 'connectors': 12288, 'reason_seconds': 29.386822544998722, 'target_bounds_sha256': '2216633a28e871f9ddd1b8957f3097e826462315a73ebe9ab84d9f44394ca3f7'},\n", + "]\n", + "\n", + "def json_lines(stdout: str):\n", + " rows = []\n", + " for line in stdout.splitlines():\n", + " if line.startswith('{'):\n", + " try:\n", + " rows.append(json.loads(line))\n", + " except json.JSONDecodeError:\n", + " pass\n", + " return rows\n", + "\n", + "if LIVE:\n", + " env = {**os.environ, 'PYTHONPATH': f\"{REPO / 'src'}:{REPO}\", 'PYTHONHASHSEED': '0'}\n", + " gpu_cmd = [\n", + " sys.executable, str(MINIMAL / 'benchmark_srdatalog.py'), '--no-compile',\n", + " '--case', '1,1,1', '--case', '4,4,2', '--case', '8,32,4',\n", + " '--case', '12,128,8', '--case', '16,256,8',\n", + " ]\n", + " gpu_run = subprocess.run(gpu_cmd, cwd=REPO, env=env, text=True, capture_output=True, timeout=30, check=False)\n", + " if gpu_run.returncode != 0:\n", + " raise RuntimeError(gpu_run.stdout + gpu_run.stderr)\n", + " gpu_results = [row for row in json_lines(gpu_run.stdout) if row['connectors'] > 1]\n", + "\n", + " py_cmd = [\n", + " sys.executable, str(MINIMAL / 'benchmark_pyreason.py'), '--warmup',\n", + " '--case', '4,4,2', '--case', '8,32,4', '--case', '12,128,8',\n", + " ]\n", + " try:\n", + " py_run = subprocess.run(\n", + " py_cmd, cwd=REPO, env=env, text=True, capture_output=True,\n", + " timeout=PYREASON_SUITE_TIMEOUT_SECONDS, check=False,\n", + " )\n", + " if py_run.returncode != 0:\n", + " raise RuntimeError(py_run.stdout + py_run.stderr)\n", + " pyreason_results = json_lines(py_run.stdout)\n", + " pyreason_status = 'live; suite completed within timeout'\n", + " except subprocess.TimeoutExpired as exc:\n", + " partial = exc.stdout or ''\n", + " if isinstance(partial, bytes):\n", + " partial = partial.decode(errors='replace')\n", + " pyreason_results = json_lines(partial)\n", + " pyreason_status = f'live; stopped at {PYREASON_SUITE_TIMEOUT_SECONDS}s timeout'\n", + " benchmark_mode = 'live'\n", + "else:\n", + " gpu_results = VERIFIED_GPU\n", + " pyreason_results = VERIFIED_PYREASON\n", + " pyreason_status = 'verified snapshot; live harness retains 120s timeout'\n", + " benchmark_mode = 'verified 2026-07-16 snapshot'\n", + "\n", + "gpu_by_size = {row['connectors']: row for row in gpu_results}\n", + "py_by_size = {row['connectors']: row for row in pyreason_results}\n", + "benchmark_rows = []\n", + "for transitions in sorted(gpu_by_size):\n", + " gpu = gpu_by_size[transitions]\n", + " py = py_by_size.get(transitions)\n", + " benchmark_rows.append({\n", + " 'depth×width×fanout': f\"{gpu['depth']}×{gpu['width']}×{gpu['fanout']}\",\n", + " 'transitions': transitions,\n", + " 'PyReason reason (ms)': None if py is None else 1000 * py['reason_seconds'],\n", + " 'SRDatalog GPU run (ms)': 1000 * gpu['run_seconds'],\n", + " 'speedup': None if py is None else py['reason_seconds'] / gpu['run_seconds'],\n", + " 'digest parity': 'not run (size guard)' if py is None else ('exact' if py['target_bounds_sha256'] == gpu['target_bounds_sha256'] else 'MISMATCH'),\n", + " })\n", + "benchmark_df = pd.DataFrame(benchmark_rows)\n", + "display(benchmark_df.style.format({\n", + " 'PyReason reason (ms)': lambda value: '—' if pd.isna(value) else f'{value:,.2f}',\n", + " 'SRDatalog GPU run (ms)': '{:,.2f}',\n", + " 'speedup': lambda value: '—' if pd.isna(value) else f'{value:,.1f}×',\n", + "}))\n", + "print('benchmark mode:', benchmark_mode)\n", + "print('PyReason status:', pyreason_status)\n", + "assert set(benchmark_df.loc[benchmark_df['digest parity'] != 'not run (size guard)', 'digest parity']) == {'exact'}" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "e404baa7", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-17T16:26:25.881636Z", + "iopub.status.busy": "2026-07-17T16:26:25.881347Z", + "iopub.status.idle": "2026-07-17T16:26:26.335199Z", + "shell.execute_reply": "2026-07-17T16:26:26.334713Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "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WmLP778aNWoU+36u9tRTT6khISFqXl7eNet89dVXRT6TfNWqVVMBddWqVVblr7zyilqmTBn1yJEjVuXPP/+86uXlpZ46dcrm6zdt2lTt3bv3Dd9LSc7r2LGjGhwcrJ48edKq3GQyWf67uH7csmWLCqiffvqppcyW7+7x48dVQC1btqyakJBgKf/hhx9UQP3xxx8tZY0bN1YrV66spqamWsp+//13FSiSD0KIm5M8CiWE8GheXl4MHjyYLVu2WD0q8vnnn1OhQgW6du1qt2t9/PHHlCtXjvLly9OyZUvWrl3LhAkTGDt2rKXOV199Rf369alXrx6XLl2y/HP77bcDsG7dOkvdwndSkpOTuXTpEp06deLff/8lOTkZgLCwMAB++ukncnNzi43r559/xsvLizFjxliVjxs3DlVVWblypVV5t27drO6yNGnShJCQEP7991/AfAfkm2++oW/fvqiqavU+evToQXJyMjt37rRqc+jQoTbdGQoLCyM9Pf2GjxVdT/Xq1enRo4dV2VdffUWHDh0IDw+3irdbt24YjUY2bNhg8/XDwsL4+++/+eeffzTFdaPzLl68yIYNG3jkkUeoWrWq1TFFUSz/Xbgfc3NzuXz5MrVq1SIsLKxIv9tq0KBBhIeHW1536NABwPKZnz17ln379vHwww8TFBRkqdepUycaN25comsKITyPDCyEEB4vf3J2/iTu//77j40bNzJ48GC8vLzsdp277rqL1atXs2LFCqZOnYqiKGRkZGAwFPyv9p9//uHvv/+mXLlyVv/UqVMHwGouyObNm+nWrRtlypQhLCyMcuXK8X//938AloFFp06dGDBgAC+99BKRkZHcddddLFy40Gq+xsmTJ4mOjiY4ONgq3vzHh06ePGlVfvUvtQDh4eEkJiYC5l+Ak5KSmDdvXpH3MXz48CLvA8y/7NviiSeeoE6dOvTq1YvKlSvzyCOPFDu/43qKu9Y///zDqlWrisTbrVs3q3htuf7LL79MUlISderUoXHjxjz77LPs3bv3hnHd6Lz8X+IbNWp03XYyMzOZPHmyZa5IZGQk5cqVIykpyfK90Orqzzx/kJH/med/R2rVqlXk3OLKhBA3J5ljIYTweC1atKBevXp88cUX/N///R9ffPEFqqparQZV+C/ChRmNRpuvU7lyZcsvqnfeeSeRkZGMHj2aLl260L9/f8A8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6KOPEhcXx/PPP8/ChQsZPnw4ylW7b0ZFRVGrVi1q1arFggUL6N27N3///TcVKlQgLS2NFi1a8NlnnxW5drly5QAYOnQoly9f5u2336ZatWr4+vrStm1bcnJyAKhSpQqHDx9mzZo1rF69mieeeIIZM2awfv16m9/n1c/zK4py3UnFYWFhNG3alN9//50tW7bQvXt3OnbsyODBgzly5Aj//POP5Zfw9PR0evToQY8ePfjss8+IjIzk+PHj9O7d2/Ie8hX+Jfx6sRUuy+/vG02KurrttLQ0Ro0axZgxY4rUrVq1apG4y5Urx6lTp+jRo4cl7l69enHy5El+/vlnVq9eTdeuXYmNjWXmzJnXjeVG709vk+KEEMIj7f4cfnwaxZgNmBeFUQZ/DtG3uDgwcbOTgYVWo2z8pddkhLcamSdqXz15GwAFQqLh6X12n2MB5r/k16pVq8hgId+QIUOYMGEC77zzDgcOHLBM2r2W1q1b07x5c1577TXeeecdmjdvzrJlyyhfvnyRuSn5Nm/ezPvvv8+dd94JmP9afunSJas6AQEB9O3bl759+xIbG0u9evXYt28f9evXJy8vjz///JO2bdsCcPnyZQ4fPkyDBg20doeVTp06sW7dOrZt28Zrr71GREQE9erV4/XXX6dixYrUqVMHgEOHDnH58mWmTZtGlSpVUFWVP//8s1TXtofmzZtbJpcXZ9++fVZxg3ki/dXKlSvH0KFDGTp0KB06dODZZ59l5syZ1K9fn0WLFpGenm4Z1GzevBmDwUDdunUd98aEEEJcnzEPfn0R/vzAUpRbsSVe93+GEhLlwsCEMJNHoRzF4AU9p195UXT6NmBectYBgwpbhIeH079/f5599lnuuOMOKleufMNznnzySebNm8eZM2d48MEHLasRbdy4kePHj/P7778zZswY/vvvP8C8otHixYs5ePAgf/75J0OGDCEgIMDS3qJFi/j444/Zv38///77L0uWLCEgIIBq1apRu3Zt7rrrLkaOHMmmTZvYs2cPw4YNo1KlStx1112leu+dO3fml19+wdvb2zJJu1OnTnz22WeWuxVg/uu/r68v7777Lv/++y/Lly/n9ddfL9W17eG5557jjz/+YPTo0ezevdsywX306NFA8XG/8sorVm1MnjyZH374gaNHj/L333/z008/WVb7evDBB/H392fo0KHs37+f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PICUlBQAzt+8T5gwQfZeRo4cCavVil27dvU5+5Keno7i4mL88MMPfSYJQ4YMwWuvvYaysjJ8+eWXmDt3LnQ6ndvvBwBWrlyJ999/H6dPn8a3336LnTt3IiMjo8e2gwcPhiRJ2LlzJ6qrq12rYXXt0+zZs7FgwQLXLNT999+PG264AbNnz3arT2fOnMGKFStQUlKCc+fO4dNPP8WpU6d67RcRkZIwsSAiCrKJEyfCbre7vowbDAZkZmYiNTXVNRsAALfccgveeustvPnmm8jKysLKlSvx1FNP9bo8bG8ee+wxrF69GjNnznS7riA6OhqffPIJ6urqcOutt+LnP/85Jk+ejBdeeOGq70WlUmHChAmQJKnP+oqnnnoKZ8+exY033ojk5OSrtv3HP/6B+vp63HLLLXjggQfwyCOPoF+/fm69l3YajQYrVqzAyJEjMWHCBKjVarz55ps9tr3hhhuwevVqLF++HCkpKa7VsLratGkTcnNzcccdd2DMmDEQQuDf//53t9ufehMdHY0TJ07gZz/7GYYOHYqFCxeisLAQv/71rz16b0REwSCJzjd2EhERERERXQPOWBARERERkdeYWBARERERkdeYWBARERERkdeYWBARERERkdeYWBARERERkdeYWBARERERkdeYWBARERERkdeYWBARERERkdeYWBARERERkdeYWBARERERkdeYWBARERERkdeYWBARERERkdf+PzmTEusienIhAAAAAElFTkSuQmCC", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "fig, ax = plt.subplots(figsize=(8, 4.8))\n", + "gpu_plot = benchmark_df.dropna(subset=['SRDatalog GPU run (ms)'])\n", + "py_plot = benchmark_df.dropna(subset=['PyReason reason (ms)'])\n", + "ax.loglog(gpu_plot['transitions'], gpu_plot['SRDatalog GPU run (ms)'], 'o-', linewidth=2, label='SRDatalog cached GPU run')\n", + "ax.loglog(py_plot['transitions'], py_plot['PyReason reason (ms)'], 'o-', linewidth=2, label='PyReason warm reason')\n", + "ax.set_xlabel('workflow transitions')\n", + "ax.set_ylabel('reasoning time (ms, log scale)')\n", + "ax.set_title('VulReasoner stress scaling')\n", + "ax.grid(True, which='both', alpha=0.25)\n", + "ax.legend()\n", + "plt.tight_layout()\n", + "plt.show()" + ] + }, + { + "cell_type": "markdown", + "id": "05919713", + "metadata": {}, + "source": [ + "## 7. Conclusions\n", + "\n", + "1. **The gap is semantic, not syntactic.** Boolean set semantics cannot express interval payloads, delayed keys, witness selection, and cross-rule information accumulation.\n", + "2. **The annotation callback is a grouped provenance-aware aggregate.** Tuple identities recover valid label/connector pairings; `ARG MAX(lower)` must carry the upper bound from the same witness.\n", + "3. **Two rules preserve two grouping levels.** A per-source-rule candidate relation selects one witness; only its delta is promoted into `AnalystAt`, where interval intersection combines rule winners.\n", + "4. **Two extra columns are sufficient for the range, but not for selection identity.** Candidate rows additionally carry stable connector rank; separate candidate relations encode rule identity.\n", + "5. **Correctness is exact on the supplied input.** The five temporal rows from PyReason and SRDatalog serialize to the same 135 bytes and SHA-256 `ae3d94e...b2d`.\n", + "6. **The scaling advantage appears once grounding grows.** In the recorded run, 1,024 transitions take about 353 ms in PyReason versus 17.7 ms on SRDatalog (~20×); 12,288 take 29.39 s versus 33.1 ms (~889×). SRDatalog handles 32,768 transitions in 44.6 ms, while the notebook intentionally stops expanding the PyReason oracle beyond its bounded policy.\n", + "\n", + "Implementation sources: [`annotation_fn.py`](annotation_fn.py), [`analyst_rules.csv`](rules/analyst_rules.csv), [`srdatalog_query.py`](srdatalog_query.py), [`graphml_ingest.py`](graphml_ingest.py), and [`stress_workload.py`](stress_workload.py)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "minimal_vulreasoner/.venv (3.10.12)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +}