优化雅可比矩阵计算;端口转发情况下仿真结果传输方式优化
This commit is contained in:
1 parent
3bc4be3c06
commit
aa4951b14e
28 files changed
+8038
-23
No files matched your search
+141
@@ -0,0 +1,141 @@
|
||||
{
|
||||
"input": "tests/data/test-mql-8-corrected.json",
|
||||
"inputSha256": "670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288",
|
||||
"sourceBaseline": "3bc4be3",
|
||||
"description": "Fixed early-time state that exposed cross-branch floating-point effects from gas-seeded property-cache hits. Ordinary RHS preserves that behavior; canonical finite differences must have independent column supports.",
|
||||
"time": 5.5155845013992836e-05,
|
||||
"state": [
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
1.374510850335198,
|
||||
-886868.3937230138,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0037120966016654976,
|
||||
-2395.136531414716,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.0011363561025506627,
|
||||
-733.2050606371581,
|
||||
0.00020371749666845882,
|
||||
4151.057268181278,
|
||||
0.0002037174936530032,
|
||||
4151.057075999222,
|
||||
0.00020371749666845876,
|
||||
4151.0572681812755,
|
||||
0.000201502245374349,
|
||||
4017.253141430648,
|
||||
0.00019928700201474382,
|
||||
3883.4495051773993,
|
||||
0.00019928700478265564,
|
||||
3883.4496764184146,
|
||||
0.0001992870020147439,
|
||||
3883.4495051773984,
|
||||
0.0002015022453743489,
|
||||
4017.253141430648,
|
||||
0.0005391983660665501,
|
||||
13129.739050571929,
|
||||
0.0005391982915589542,
|
||||
13129.736010626364,
|
||||
0.0005391982915589541,
|
||||
13129.736010626359,
|
||||
0.0005391983660665499,
|
||||
13129.739050571923,
|
||||
0.0005104415528318413,
|
||||
12033.97730205289,
|
||||
0.0005104416213041327,
|
||||
12033.979989602627,
|
||||
0.0005104416213041329,
|
||||
12033.97998960263,
|
||||
0.0005104415528318412,
|
||||
12033.977302052886,
|
||||
0.0018747072262331166,
|
||||
28155.052111436304,
|
||||
0.0018747072262067512,
|
||||
28155.052110739638,
|
||||
0.0018747072262067512,
|
||||
28155.052110739638,
|
||||
0.0018747072262331162,
|
||||
28155.052111436304,
|
||||
0.001874685860561648,
|
||||
28154.487192177705,
|
||||
0.0018746858605900858,
|
||||
28154.487192929002,
|
||||
0.0018746858605900858,
|
||||
28154.48719292901,
|
||||
0.001874685860561648,
|
||||
28154.487192177716,
|
||||
0.00019376421198889977,
|
||||
2.6175966521845393e-08,
|
||||
0.00019376421199359848,
|
||||
2.617596652188611e-08,
|
||||
0.00019376421199368874,
|
||||
2.6175966521887487e-08,
|
||||
0.00019376421198912823,
|
||||
2.6175966521844245e-08,
|
||||
0.00019376757063020532,
|
||||
2.61760362457673e-08,
|
||||
0.0001937675706256581,
|
||||
2.617603624573835e-08,
|
||||
0.00019376757062598263,
|
||||
2.617603624571979e-08,
|
||||
0.0001937675706301319,
|
||||
2.6176036245787994e-08,
|
||||
55155844988.81955,
|
||||
1521083.6188545576,
|
||||
0,
|
||||
0,
|
||||
4.074833511646271e-05,
|
||||
1357.9827565488304,
|
||||
4.074833755374403e-05,
|
||||
1357.9828950518809,
|
||||
4.074833755374401e-05,
|
||||
1357.9828950518806,
|
||||
4.074833511646269e-05,
|
||||
1357.98275654883
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
r"""Prepare, then optionally benchmark the production automatic Jacobian.
|
||||
|
||||
.venv/bin/python tests/manual/benchmark_native_jacobian.py \
|
||||
--input tests/data/test-mql-8-corrected.json \
|
||||
--output-dir test/jacobian-production/benchmark --warmups 1 --repeats 3 \
|
||||
--verify-jacobian
|
||||
|
||||
Add --run to build once and execute. Preparation generates C without compiling
|
||||
or solving. --verify-jacobian (--verify alias) adds one separate, untimed-for-
|
||||
statistics full-matrix diagnostic run. Ordinary runs use the production default.
|
||||
|
||||
An optional --baseline-executable must point to a frozen historical executable
|
||||
whose DEFAULT algorithm is the old dense Jacobian. No strategy selector is sent
|
||||
to either executable. The external binary hash and provenance are recorded; if
|
||||
it reports a Jacobian mode, it must report dense-difference. With no external
|
||||
baseline only current-production repeatability is compared. Historical summary
|
||||
keys dense/auto are retained; dense statistics are empty and speedComparison is
|
||||
null when no external baseline was supplied.
|
||||
|
||||
Both executables receive the same time, sampling and tolerance arguments. The
|
||||
caller must select a frozen baseline for the same model and embedded absolute
|
||||
tolerances; recorded provenance and structural checks alone do not prove this.
|
||||
Parsing and comparisons are outside process timing. Exact payload equality and
|
||||
signed-zero bit equality are reported separately, without resampling or tolerance
|
||||
relaxation. --record-differences permits failed external comparisons to be saved
|
||||
for separate trajectory review; repeatability and verification still must pass.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from array import array
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import platform
|
||||
import statistics
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PAYLOAD = ("series", "final", "finalState")
|
||||
COUNTERS = ("nfev", "acceptedSteps", "rejectedSteps", "njev", "nlu",
|
||||
"stateTransitions", "solverStarts", "jacobianRhsCalls",
|
||||
"jacobianColoredEvals", "jacobianFallbacks", "jacobianChecks",
|
||||
"jacobianMismatches", "cvodeRhsCalls", "cvodeLinearRhsCalls")
|
||||
TIMINGS = ("solveSeconds", "solveCpuSeconds", "processWallSeconds")
|
||||
INVARIANT_METADATA = ("success", "status", "backend", "method", "solver", "sundialsVersion", "simulatedUntil")
|
||||
MAX_DETAILS = 20
|
||||
|
||||
|
||||
def digest(path: Path) -> str:
|
||||
with path.open("rb") as stream:
|
||||
value = sha256()
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
value.update(block)
|
||||
return value.hexdigest()
|
||||
|
||||
|
||||
def write_json(path: Path, value: object) -> None:
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def pointer(*parts: object) -> str:
|
||||
return "/" + "/".join(str(part).replace("~", "~0").replace("/", "~1") for part in parts)
|
||||
|
||||
|
||||
def read_result(path: Path) -> tuple[dict, str]:
|
||||
def unique(items):
|
||||
result = {}
|
||||
for key, value in items:
|
||||
if key in result:
|
||||
raise ValueError(f"Duplicate JSON object key: {key!r}")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def reject(token):
|
||||
raise ValueError(f"Nonfinite JSON token: {token}")
|
||||
|
||||
def finite_float(token):
|
||||
value = float(token)
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"Nonfinite JSON number: {token}")
|
||||
return value
|
||||
|
||||
raw = path.read_bytes()
|
||||
data = json.loads(raw, parse_int=lambda s: -0.0 if s == "-0" else int(s),
|
||||
parse_float=finite_float, parse_constant=reject, object_pairs_hook=unique)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Expected a native result object")
|
||||
return data, sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def number(value, path: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"Expected numeric payload at {path}")
|
||||
try:
|
||||
result = float(value)
|
||||
except (OverflowError, ValueError) as exc:
|
||||
raise ValueError(f"Cannot represent binary64 at {path}") from exc
|
||||
if not math.isfinite(result) or (isinstance(value, int) and int(result) != value):
|
||||
raise ValueError(f"Nonfinite or inexact binary64 at {path}")
|
||||
return result
|
||||
|
||||
|
||||
def blocks(data: dict):
|
||||
for key, values in data["series"].items():
|
||||
yield pointer("series", key), values
|
||||
for key, value in data["final"].items():
|
||||
yield pointer("final", key), [value]
|
||||
yield "/finalState", data["finalState"]
|
||||
|
||||
|
||||
def validate_result(data: dict, prepared: dict, *, external_baseline: bool = False) -> dict:
|
||||
required_counters = COUNTERS[:7] if external_baseline else COUNTERS
|
||||
mode_fields = () if external_baseline else ("jacobianMode",)
|
||||
for key in (*PAYLOAD, *required_counters, *INVARIANT_METADATA, *mode_fields, "maxAcceptedStep", "solveSeconds", "solveCpuSeconds"):
|
||||
if key not in data:
|
||||
raise ValueError(f"Missing result field: {key}")
|
||||
if not isinstance(data["series"], dict) or not isinstance(data["final"], dict) or not isinstance(data["finalState"], list):
|
||||
raise ValueError("Invalid series/final/finalState structure")
|
||||
expected = set(prepared["outputKeys"])
|
||||
if set(data["series"]) != expected | {"time"} or set(data["final"]) != expected:
|
||||
raise ValueError("Result output key sets do not match the generated manifest")
|
||||
if len(data["finalState"]) != prepared["stateCount"]:
|
||||
raise ValueError("finalState length does not match the generated manifest")
|
||||
times = data["series"]["time"]
|
||||
if not isinstance(times, list) or not times:
|
||||
raise ValueError("Full sampled output is required")
|
||||
for key, values in data["series"].items():
|
||||
if not isinstance(values, list) or len(values) != len(times):
|
||||
raise ValueError(f"Series column length differs from time: {key}")
|
||||
for key in COUNTERS:
|
||||
if external_baseline and key not in data:
|
||||
continue
|
||||
if type(data[key]) is not int or data[key] < 0:
|
||||
raise ValueError(f"Invalid nonnegative counter: {key}")
|
||||
if data["success"] is not True or data["status"] != "completed":
|
||||
raise ValueError(f"Native solve did not complete: {data.get('message')}")
|
||||
if (data["backend"], data["method"], data["solver"]) != ("native-c", "BDF", "CVODE"):
|
||||
raise ValueError("Unexpected backend/integrator")
|
||||
for key in ("simulatedUntil", "maxAcceptedStep", "solveSeconds", "solveCpuSeconds"):
|
||||
number(data[key], pointer(key))
|
||||
if data["solveSeconds"] <= 0 or data["solveCpuSeconds"] < 0:
|
||||
raise ValueError("Invalid reported solve duration")
|
||||
cells = negative_zero = positive_zero = 0
|
||||
for path, values in blocks(data):
|
||||
for index, value in enumerate(values):
|
||||
value = number(value, path + "/" + str(index))
|
||||
cells += 1
|
||||
if value == 0:
|
||||
if math.copysign(1.0, value) < 0:
|
||||
negative_zero += 1
|
||||
else:
|
||||
positive_zero += 1
|
||||
cfg = prepared["settings"]
|
||||
if times[0] != cfg["t_start"] or times[-1] != cfg["t_stop"] or data["simulatedUntil"] != cfg["t_stop"]:
|
||||
raise ValueError("Result does not cover the entire requested interval")
|
||||
if any(a >= b for a, b in zip(times, times[1:])):
|
||||
raise ValueError("Sample times must be strictly increasing")
|
||||
if data["maxAcceptedStep"] > cfg["max_step"] * (1 + 1e-14):
|
||||
raise ValueError("Reported accepted step exceeds configured maximum")
|
||||
# Match the runtime's start + index * sample_step arithmetic exactly.
|
||||
regular = []
|
||||
index = 0
|
||||
while (value := cfg["t_start"] + index * prepared["sampleStep"]) <= cfg["t_stop"]:
|
||||
regular.append(value)
|
||||
index += 1
|
||||
actual_times = set(times)
|
||||
missing_regular = [value for value in regular if value not in actual_times]
|
||||
regular_set = set(regular)
|
||||
extra = [value for value in times if value not in regular_set]
|
||||
if missing_regular:
|
||||
raise ValueError(f"Missing regular samples: {missing_regular[:MAX_DETAILS]}")
|
||||
return {"sampleCount": len(times), "seriesColumns": len(data["series"]),
|
||||
"finalScalars": len(data["final"]), "finalStateValues": len(data["finalState"]),
|
||||
"payloadValues": cells, "negativeZeroValues": negative_zero, "positiveZeroValues": positive_zero,
|
||||
"regularSampleCount": len(regular), "extraSampleTimes": extra,
|
||||
"extraSampleInterpretation": "Off-grid saved points, usually events; a non-grid final endpoint may also appear.",
|
||||
"counterAccountingMatches": (data["nfev"] == data["cvodeRhsCalls"] + data["cvodeLinearRhsCalls"] + data["jacobianRhsCalls"]
|
||||
if all(k in data for k in ("cvodeRhsCalls", "cvodeLinearRhsCalls", "jacobianRhsCalls")) else None),
|
||||
"missingHistoricalCounters": [k for k in COUNTERS if k not in data]}
|
||||
|
||||
|
||||
def compare_payload(baseline: dict, candidate: dict) -> dict:
|
||||
"""Exact values and separately exact bits; never compare unaligned series."""
|
||||
structure = []
|
||||
for section in ("series", "final"):
|
||||
left, right = baseline[section], candidate[section]
|
||||
if left.keys() != right.keys():
|
||||
structure.append({"path": pointer(section), "missing": sorted(left.keys() - right.keys()), "extra": sorted(right.keys() - left.keys())})
|
||||
for key in baseline["series"].keys() & candidate["series"].keys():
|
||||
a, b = len(baseline["series"][key]), len(candidate["series"][key])
|
||||
if a != b:
|
||||
structure.append({"path": pointer("series", key), "baselineLength": a, "candidateLength": b})
|
||||
if len(baseline["finalState"]) != len(candidate["finalState"]):
|
||||
structure.append({"path": "/finalState", "baselineLength": len(baseline["finalState"]), "candidateLength": len(candidate["finalState"])})
|
||||
left_times, right_times = baseline["series"]["time"], candidate["series"]["time"]
|
||||
same_times = left_times == right_times
|
||||
time_differences = []
|
||||
for i, (a, b) in enumerate(zip(left_times, right_times)):
|
||||
if a != b:
|
||||
time_differences.append({"index": i, "baseline": a, "candidate": b})
|
||||
if len(time_differences) == MAX_DETAILS:
|
||||
break
|
||||
metadata = [{"key": key, "baseline": baseline.get(key), "candidate": candidate.get(key)}
|
||||
for key in INVARIANT_METADATA if baseline.get(key) != candidate.get(key)]
|
||||
numerical = bit_count = zero_signs = compared = 0
|
||||
details = []
|
||||
compared_blocks = 0
|
||||
pairs = []
|
||||
if same_times:
|
||||
for key in sorted(baseline["series"].keys() & candidate["series"].keys()):
|
||||
pairs.append((pointer("series", key), baseline["series"][key], candidate["series"][key]))
|
||||
for key in sorted(baseline["final"].keys() & candidate["final"].keys()):
|
||||
pairs.append((pointer("final", key), [baseline["final"][key]], [candidate["final"][key]]))
|
||||
pairs.append(("/finalState", baseline["finalState"], candidate["finalState"]))
|
||||
for path, left, right in pairs:
|
||||
if len(left) != len(right):
|
||||
continue
|
||||
compared_blocks += 1
|
||||
compared += len(left)
|
||||
# Fast whole-block bit check; inspect individual cells only on differences.
|
||||
if array("d", left).tobytes() == array("d", right).tobytes():
|
||||
continue
|
||||
for index, (a, b) in enumerate(zip(left, right, strict=True)):
|
||||
packed_a, packed_b = struct.pack("<d", a), struct.pack("<d", b)
|
||||
if packed_a == packed_b:
|
||||
continue
|
||||
bit_count += 1
|
||||
numerical += a != b
|
||||
zero_signs += a == 0 and b == 0
|
||||
if len(details) < MAX_DETAILS:
|
||||
details.append({"path": path if path.startswith("/final/") else path + "/" + str(index), "baseline": a, "candidate": b,
|
||||
"numericallyEqual": a == b, "baselineBitsLE": packed_a.hex(), "candidateBitsLE": packed_b.hex()})
|
||||
complete = not structure and same_times
|
||||
return {"passed": complete and not metadata and numerical == 0,
|
||||
"comparisonContract": "Exact payload numeric equality; signed-zero differences do not fail numeric equality and are reported separately. Solver work counters are observations, not invariants.",
|
||||
"structureEqual": not structure, "structureDifferences": structure[:MAX_DETAILS],
|
||||
"structureDifferenceCount": len(structure), "metadataDifferences": metadata,
|
||||
"timeAxis": {"numericallyEqual": same_times, "baselineLength": len(left_times), "candidateLength": len(right_times),
|
||||
"firstIndexDifferences": time_differences, "seriesCompared": same_times,
|
||||
"interpretation": "Time differences are index diagnostics only; unequal time axes disable series-value comparison. No interpolation or event removal."},
|
||||
"allPayloadCompared": complete, "comparedBlocks": compared_blocks, "comparedValues": compared,
|
||||
"numericallyUnequalValues": numerical, "differentBits": bit_count, "signedZeroDifferences": zero_signs,
|
||||
"allPayloadBitsEqual": complete and bit_count == 0, "firstDifferences": details,
|
||||
"counterDifferences": {key: {"baseline": baseline.get(key), "candidate": candidate.get(key)}
|
||||
for key in COUNTERS if baseline.get(key) != candidate.get(key)},
|
||||
"maxAcceptedStep": {"baseline": baseline.get("maxAcceptedStep"), "candidate": candidate.get("maxAcceptedStep")}}
|
||||
|
||||
|
||||
def stats(values) -> dict:
|
||||
values = list(values)
|
||||
return {"n": len(values), "median": statistics.median(values) if values else None,
|
||||
"min": min(values) if values else None, "max": max(values) if values else None, "values": values}
|
||||
|
||||
|
||||
def prepare(args) -> tuple[dict, object]:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.main import compile_system_xml_network
|
||||
from app.simulation.backends import simulation_config
|
||||
from app.simulation.native_codegen.compiler import compile_native_program
|
||||
from app.simulation.native_codegen.input import load_input
|
||||
from app.simulation.native_codegen.tolerances import state_absolute_tolerance
|
||||
|
||||
output = args.output_dir.resolve()
|
||||
if output == ROOT / "test" or not output.is_relative_to(ROOT / "test"):
|
||||
raise ValueError("Choose an output subdirectory beneath the repository's ignored test/ directory")
|
||||
if any((output / name).exists() for name in ("summary.json", "dense", "auto", "verify")):
|
||||
raise ValueError("Run artifacts already exist; choose a fresh output directory")
|
||||
external = None
|
||||
if args.baseline_executable is not None:
|
||||
executable = args.baseline_executable.resolve()
|
||||
if not executable.is_file() or not os.access(executable, os.X_OK):
|
||||
raise ValueError("--baseline-executable must be an existing executable from a frozen historical version")
|
||||
manifest = executable.parent / "manifest.json"
|
||||
external = {"executable": str(executable), "sha256": digest(executable),
|
||||
"manifestPath": str(manifest) if manifest.is_file() else None,
|
||||
"manifestSha256": digest(manifest) if manifest.is_file() else None,
|
||||
"strategy": "historical executable default; dense-difference required if reported",
|
||||
"modelAndEmbeddedToleranceProvenance": "Caller-supplied frozen model; current manifest checks payload dimensions, not full physical equivalence"}
|
||||
started = time.perf_counter()
|
||||
xml, document = load_input(args.input)
|
||||
cfg = simulation_config(document.simulation)
|
||||
if cfg.method != "BDF" or cfg.rtol != 1e-8 or cfg.atol != 1e-8 or cfg.first_step is not None:
|
||||
raise ValueError("Input must use BDF, production rtol=1e-8, generated atol and automatic first step")
|
||||
step = document.simulation.sample_step
|
||||
if not all(math.isfinite(v) for v in (cfg.t_start, cfg.t_stop, cfg.max_step, step)) or not (cfg.t_stop > cfg.t_start and cfg.max_step > 0 and step > 0):
|
||||
raise ValueError("Invalid finite simulation interval/step settings")
|
||||
if (cfg.t_stop - cfg.t_start) / step > 1000000 or cfg.t_start + step == cfg.t_start:
|
||||
raise ValueError("Invalid or excessive sampling grid")
|
||||
program = compile_native_program(compile_system_xml_network(document))
|
||||
if external and external["manifestPath"]:
|
||||
frozen_manifest = json.loads(Path(external["manifestPath"]).read_text())
|
||||
if frozen_manifest.get("stateKeys") != list(program.state_keys):
|
||||
raise ValueError("Frozen baseline manifest state keys/order differ from the current model")
|
||||
frozen_outputs = {v["key"] for v in frozen_manifest.get("variables", [])}
|
||||
if frozen_outputs != {v.key for v in program.variables}:
|
||||
raise ValueError("Frozen baseline manifest output keys differ from the current model")
|
||||
external["manifestStateAndOutputContractMatched"] = True
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output / "input.xml").write_bytes(xml)
|
||||
if args.input.suffix.lower() == ".json":
|
||||
(output / "input.json").write_bytes(args.input.read_bytes())
|
||||
(output / "model.c").write_text(program.source, encoding="utf-8")
|
||||
(output / "model.h").write_text(program.header, encoding="utf-8")
|
||||
write_json(output / "model-contract.json", program.manifest())
|
||||
source_paths = sorted((ROOT / "native").rglob("*.c")) + sorted((ROOT / "native").rglob("*.h")) + sorted((ROOT / "app/simulation/native_codegen").glob("*.py"))
|
||||
prepared = {"schemaVersion": 1, "preparedOnly": not args.run, "input": str(args.input.resolve()),
|
||||
"inputSha256": digest(args.input), "xmlSha256": sha256(xml).hexdigest(),
|
||||
"scriptSha256": digest(Path(__file__)), "modelSourceSha256": digest(output / "model.c"),
|
||||
"modelHeaderSha256": digest(output / "model.h"), "settings": vars(cfg), "sampleStep": step,
|
||||
"stateCount": len(program.state_keys), "stateKeys": list(program.state_keys),
|
||||
"stateAbsoluteTolerances": [float(state_absolute_tolerance(k)) for k in program.state_keys],
|
||||
"outputKeys": [v.key for v in program.variables], "modelContract": program.manifest(),
|
||||
"sourceHashes": {str(p.relative_to(ROOT)): digest(p) for p in source_paths},
|
||||
"environment": {"platform": platform.platform(), "python": sys.version, "machine": platform.machine()},
|
||||
"warmupsPerMode": args.warmups, "repeatsPerMode": args.repeats,
|
||||
"algorithm": "production-automatic", "externalBaseline": external,
|
||||
"activeModes": ["dense", "auto"] if external else ["auto"],
|
||||
"verifyRequested": args.verify, "recordDifferences": args.record_differences, "nativeTimeoutSeconds": args.timeout,
|
||||
"processTimeoutSeconds": args.timeout + 10, "preparationSeconds": time.perf_counter() - started,
|
||||
"timingContract": "Production automatic executable plus an optional caller-supplied frozen external baseline; complete sampled output. C-reported solve wall/CPU and subprocess creation-through-reap wall only. Build, parsing, validation and comparisons excluded. No independently measured write/projection stage; process-minus-solve is not called write time. Ordinary file writes, no fsync.",
|
||||
"comparisonContract": "Exact structure, numeric values and time axis for series/final/finalState. Bits, including signed zero, are separately reported. No loosened tolerance or interpolation. Counters may differ.",
|
||||
"pairOrder": [{"pair": i + 1, "warmup": i < args.warmups,
|
||||
"modes": (["dense", "auto"] if i % 2 == 0 else ["auto", "dense"]) if external else ["auto"]}
|
||||
for i in range(args.warmups + args.repeats)]}
|
||||
write_json(output / "prepared.json", prepared)
|
||||
return prepared, program
|
||||
|
||||
|
||||
def execute(args, prepared: dict, program) -> dict:
|
||||
from app.simulation.native_codegen.build import build_native
|
||||
|
||||
output = args.output_dir.resolve()
|
||||
summary = {"schemaVersion": 1, "complete": False, "passed": False, "prepared": prepared,
|
||||
"errors": [], "rows": [], "pairs": [], "verify": None, "statistics": None,
|
||||
"externalBaseline": prepared["externalBaseline"],
|
||||
"speedComparison": None, "strictFailureMeans": "Exact equivalence was not established; retain outputs for independent convergence diagnostics. No automatic acceptance-tolerance change."}
|
||||
rows, pairs = summary["rows"], summary["pairs"]
|
||||
|
||||
def save():
|
||||
write_json(output / "summary.json", summary)
|
||||
|
||||
def run(mode: str, label: str, *, pair=None, warmup=False, diagnostic=False):
|
||||
directory = output / mode / label
|
||||
directory.mkdir(parents=True, exist_ok=False)
|
||||
cfg = prepared["settings"]
|
||||
executable = Path(prepared["externalBaseline"]["executable"]) if mode == "dense" else build.executable
|
||||
command = [str(executable), "--method", "BDF",
|
||||
"--start", str(cfg["t_start"]), "--stop", str(cfg["t_stop"]),
|
||||
"--sample-step", str(prepared["sampleStep"]), "--max-step", str(cfg["max_step"]),
|
||||
"--rtol", "1e-8", "--timeout", str(args.timeout),
|
||||
"--output", str(directory / "result.json"), "--result-index", str(directory / "result-index.json"),
|
||||
"--cancel-file", str(directory / "cancel.request")]
|
||||
if diagnostic:
|
||||
command.append("--verify-jacobian")
|
||||
row = {"mode": mode, "label": label, "pair": pair, "warmup": warmup, "diagnostic": diagnostic,
|
||||
"includedInStatistics": not warmup and not diagnostic, "directory": str(directory), "command": command,
|
||||
"completed": False, "resultValidation": None, "externalBaseline": mode == "dense"}
|
||||
rows.append(row)
|
||||
write_json(directory / "command.json", command)
|
||||
environment = dict(os.environ)
|
||||
for key in ("NATIVE_COMPUTE_PROFILE", "NATIVE_STAGE_PROFILE"):
|
||||
environment.pop(key, None)
|
||||
try:
|
||||
with (directory / "stdout.log").open("wb") as stdout, (directory / "stderr.log").open("wb") as stderr:
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
process = subprocess.run(command, cwd=executable.parent, env=environment, stdin=subprocess.DEVNULL,
|
||||
stdout=stdout, stderr=stderr, timeout=args.timeout + 10)
|
||||
row["exitCode"] = process.returncode
|
||||
finally:
|
||||
row["processWallSeconds"] = time.perf_counter() - started
|
||||
result_path = directory / "result.json"
|
||||
if not result_path.is_file():
|
||||
raise RuntimeError(f"No native result: {directory}")
|
||||
data, result_hash = read_result(result_path)
|
||||
row.update({key: value for key, value in data.items() if key not in PAYLOAD})
|
||||
row["resultSha256"] = result_hash
|
||||
row["resultBytes"] = result_path.stat().st_size
|
||||
row["resultValidation"] = validate_result(data, prepared, external_baseline=mode == "dense")
|
||||
if row["exitCode"] != 0:
|
||||
raise RuntimeError(f"Native exit code {row['exitCode']}: {directory}")
|
||||
if row["resultValidation"]["counterAccountingMatches"] is False:
|
||||
raise RuntimeError(f"RHS counter accounting failed: {directory}")
|
||||
if mode == "dense" and data.get("jacobianMode") not in (None, "dense-difference"):
|
||||
raise RuntimeError("External baseline is not a frozen default-dense executable; current production cannot emulate the old strategy")
|
||||
if mode == "dense":
|
||||
row["historicalStrategyEvidence"] = "reported-dense" if data.get("jacobianMode") else "unreported: caller-supplied frozen provenance"
|
||||
if diagnostic and (data["jacobianChecks"] <= 0 or data["jacobianMismatches"] != 0 or
|
||||
data["jacobianChecks"] != data["jacobianColoredEvals"]):
|
||||
raise RuntimeError("Verify mode did not validate every computed colored Jacobian without mismatches")
|
||||
row["completed"] = True
|
||||
print(f"{mode}/{label}: solve={row['solveSeconds']:.6f}s process={row['processWallSeconds']:.6f}s nfev={row['nfev']}", flush=True)
|
||||
return data
|
||||
except Exception as exc:
|
||||
row["error"] = f"{type(exc).__name__}: {exc}"
|
||||
raise
|
||||
finally:
|
||||
write_json(directory / "run.json", row)
|
||||
save()
|
||||
|
||||
try:
|
||||
# Build current production once. An optional baseline is never built or modified.
|
||||
build = build_native(program, cache_dir=output / "cache")
|
||||
summary["build"] = {"executable": str(build.executable), "buildKey": build.manifest["buildKey"],
|
||||
"cacheHit": build.cache_hit, "seconds": build.seconds, "manifest": build.manifest}
|
||||
if prepared["externalBaseline"]:
|
||||
if digest(Path(prepared["externalBaseline"]["executable"])) != prepared["externalBaseline"]["sha256"]:
|
||||
raise RuntimeError("Frozen external baseline changed after preparation")
|
||||
if digest(build.executable) == prepared["externalBaseline"]["sha256"]:
|
||||
raise RuntimeError("External baseline equals the current production executable")
|
||||
save()
|
||||
verify_data = run("verify", "validation", diagnostic=True) if args.verify else None
|
||||
first_auto = first_dense = None
|
||||
warmup_count = measured_count = 0
|
||||
for planned in prepared["pairOrder"]:
|
||||
is_warmup = planned["warmup"]
|
||||
if is_warmup:
|
||||
warmup_count += 1
|
||||
label = f"warmup-{warmup_count}"
|
||||
else:
|
||||
measured_count += 1
|
||||
label = f"run-{measured_count}"
|
||||
results = {mode: run(mode, label, pair=planned["pair"], warmup=is_warmup) for mode in planned["modes"]}
|
||||
comparison = compare_payload(results["dense"], results["auto"]) if "dense" in results else None
|
||||
auto_repeat = compare_payload(first_auto, results["auto"]) if first_auto is not None else None
|
||||
dense_repeat = compare_payload(first_dense, results["dense"]) if first_dense is not None else None
|
||||
if first_auto is None:
|
||||
first_auto = results["auto"]
|
||||
first_dense = results.get("dense")
|
||||
if verify_data is not None:
|
||||
summary["verify"] = compare_payload(results["auto"], verify_data)
|
||||
summary["verify"]["modes"] = "production default versus --verify-jacobian: diagnostic must preserve trajectory"
|
||||
verify_data = None
|
||||
record = {"pair": planned["pair"], "label": label, "warmup": is_warmup, "order": planned["modes"],
|
||||
"externalBaseline": prepared["externalBaseline"] is not None,
|
||||
"denseVsAuto": comparison, "denseRepeatVsFirstDense": dense_repeat,
|
||||
"autoRepeatVsFirstAuto": auto_repeat}
|
||||
pairs.append(record)
|
||||
write_json(output / f"comparison-{label}.json", record)
|
||||
save()
|
||||
if any(check is not None and not check["passed"] for check in (auto_repeat, dense_repeat, summary["verify"])):
|
||||
raise RuntimeError(f"Within-algorithm or default/verify reproducibility failed at {label}")
|
||||
if comparison is not None and not comparison["passed"] and not args.record_differences:
|
||||
raise RuntimeError(f"Strict external-baseline comparison failed at {label}; raw artifacts retained for convergence diagnostics")
|
||||
summary["statistics"] = {mode: {key: stats(row[key] for row in rows if row["mode"] == mode and row["includedInStatistics"] and key in row)
|
||||
for key in (*TIMINGS, *COUNTERS, "resultBytes")} for mode in ("dense", "auto")}
|
||||
if prepared["externalBaseline"]:
|
||||
ratios = {}
|
||||
for metric in TIMINGS:
|
||||
matched = []
|
||||
for pair in pairs:
|
||||
if pair["warmup"]:
|
||||
continue
|
||||
selected = {row["mode"]: row for row in rows if row["pair"] == pair["pair"]}
|
||||
old, new = selected["dense"][metric], selected["auto"][metric]
|
||||
if old <= 0 or new <= 0:
|
||||
raise RuntimeError(f"Cannot form a positive-duration comparison for {metric}")
|
||||
matched.append({"pair": pair["pair"], "dense": old, "auto": new,
|
||||
"reductionPercent": 100 * (old - new) / old, "speedup": old / new})
|
||||
old_median = summary["statistics"]["dense"][metric]["median"]
|
||||
new_median = summary["statistics"]["auto"][metric]["median"]
|
||||
ratios[metric] = {"pairs": matched,
|
||||
"pairedReductionPercent": stats(p["reductionPercent"] for p in matched),
|
||||
"pairedSpeedup": stats(p["speedup"] for p in matched),
|
||||
"ratioOfGroupMedians": {"denseMedian": old_median, "autoMedian": new_median,
|
||||
"reductionPercent": 100 * (old_median - new_median) / old_median,
|
||||
"speedup": old_median / new_median}}
|
||||
summary["speedComparison"] = {"method": "Alternating serial frozen-external-baseline/production pairs, distinct executables; paired ratios and ratio of group medians are distinct estimates. Warmups and verification excluded.", "externalBaseline": True, "metrics": ratios}
|
||||
checks = [check for pair in pairs for check in (pair["denseVsAuto"], pair["denseRepeatVsFirstDense"], pair["autoRepeatVsFirstAuto"]) if check is not None]
|
||||
if summary["verify"] is not None:
|
||||
checks.append(summary["verify"])
|
||||
summary["complete"] = True
|
||||
summary["comparisonCount"] = len(checks)
|
||||
summary["passed"] = all(check["passed"] for check in checks)
|
||||
summary["numericalAcceptance"] = ("Exact external-baseline equivalence and repeatability" if summary["passed"] else "Requires separate trajectory/convergence review; no tolerance gate applied") if prepared["externalBaseline"] else "Production repeatability/verification only; no external accuracy comparison"
|
||||
summary["allPayloadBitsEqual"] = all(check["allPayloadBitsEqual"] for check in checks) if checks else None
|
||||
save()
|
||||
except Exception as exc:
|
||||
summary["errors"].append(f"{type(exc).__name__}: {exc}")
|
||||
save()
|
||||
print(summary["errors"][-1], file=sys.stderr, flush=True)
|
||||
return summary
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--input", type=Path, default=ROOT / "tests/data/test-mql-8-corrected.json")
|
||||
parser.add_argument("--output-dir", required=True, type=Path)
|
||||
parser.add_argument("--run", action="store_true", help="Build once and execute; omitted means preparation only")
|
||||
parser.add_argument("--warmups", type=int, default=1)
|
||||
parser.add_argument("--repeats", type=int, default=3)
|
||||
parser.add_argument("--verify-jacobian", "--verify", dest="verify", action="store_true", help="Also execute one full-matrix diagnostic run, excluded from timing statistics")
|
||||
parser.add_argument("--baseline-executable", type=Path, help="Optional frozen historical executable with default dense strategy; never built or modified by this tool")
|
||||
parser.add_argument("--record-differences", action="store_true", help="Complete timings while retaining failed strict external-baseline comparisons; does not accept numerical differences")
|
||||
parser.add_argument("--timeout", type=float, default=120, help="Per-process native timeout; Python allows 10 s exit grace")
|
||||
if any(arg == "--jacobian" or arg.startswith("--jacobian=") for arg in sys.argv[1:]):
|
||||
parser.error("--jacobian strategy selection was removed; use the production default or --verify-jacobian. Dense requests require a frozen historical version (benchmark only: --baseline-executable).")
|
||||
args = parser.parse_args()
|
||||
if args.warmups < 0 or args.repeats < 1 or not math.isfinite(args.timeout) or args.timeout <= 0:
|
||||
parser.error("Require warmups >= 0, repeats >= 1, finite timeout > 0")
|
||||
try:
|
||||
prepared, program = prepare(args)
|
||||
except Exception as exc:
|
||||
print(f"Preparation failed: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
print(f"Prepared: {args.output_dir.resolve() / 'prepared.json'} (no compilation or solve during preparation)", flush=True)
|
||||
if not args.run:
|
||||
return 0
|
||||
summary = execute(args, prepared, program)
|
||||
if summary["complete"]:
|
||||
print(json.dumps(summary["speedComparison"] if summary["speedComparison"] is not None else summary["statistics"]["auto"], ensure_ascii=False, indent=2), flush=True)
|
||||
return 0 if summary["passed"] or (args.record_differences and summary["complete"]) else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,301 @@
|
||||
r"""Diagnose complete native trajectories without changing acceptance tolerances.
|
||||
|
||||
.venv/bin/python tests/manual/compare_jacobian_trajectories.py \
|
||||
--baseline old/result.json --candidate new/result.json \
|
||||
--manifest new/cache/KEY/manifest.json --output test/jacobian/comparison.json
|
||||
|
||||
Optional --reference tight/result.json compares each trajectory to that supplied
|
||||
reference. Its precision/convergence must be established separately. There is no
|
||||
numerical pass threshold here: successful analysis is not accuracy acceptance.
|
||||
The ordinary grid defaults to 0..10 s at .01 s. Only exactly shared grid times
|
||||
are compared; all off-grid saved points are listed and examined separately.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.simulation.native_codegen.tolerances import state_absolute_tolerance
|
||||
|
||||
RTOL = 1e-8
|
||||
PAYLOAD = {"series", "final", "finalState"}
|
||||
|
||||
|
||||
def read_json(path: Path):
|
||||
def unique(items):
|
||||
value = {}
|
||||
for key, item in items:
|
||||
if key in value:
|
||||
raise ValueError(f"Duplicate JSON key: {key}")
|
||||
value[key] = item
|
||||
return value
|
||||
|
||||
def reject(token):
|
||||
raise ValueError(f"Nonfinite JSON token: {token}")
|
||||
|
||||
def parsed_float(token):
|
||||
value = float(token)
|
||||
if not math.isfinite(value):
|
||||
raise ValueError(f"Nonfinite JSON number: {token}")
|
||||
return value
|
||||
|
||||
raw = path.read_bytes()
|
||||
value = json.loads(raw, parse_float=parsed_float, parse_int=lambda token: -0.0 if token == "-0" else int(token),
|
||||
parse_constant=reject, object_pairs_hook=unique)
|
||||
return value, {"path": str(path.resolve()), "sha256": sha256(raw).hexdigest(), "bytes": len(raw)}
|
||||
|
||||
|
||||
def finite(value):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError("Expected a finite numeric payload")
|
||||
number = float(value)
|
||||
if not math.isfinite(number) or (isinstance(value, int) and int(number) != value):
|
||||
raise ValueError("Payload is nonfinite or not exactly representable as binary64")
|
||||
return number
|
||||
|
||||
|
||||
def rms(values):
|
||||
return math.hypot(*values) / math.sqrt(len(values)) if values else None
|
||||
|
||||
|
||||
def peak(values, times):
|
||||
i = max(range(len(values)), key=lambda i: abs(values[i]))
|
||||
return {"absolute": abs(values[i]), "value": values[i], "time": times[i]}
|
||||
|
||||
|
||||
def validate(data, metadata, states, start, stop):
|
||||
if not isinstance(data, dict) or data.get("success") is not True or data.get("status") != "completed":
|
||||
raise ValueError("A completed native result is required")
|
||||
if not isinstance(data.get("series"), dict) or not isinstance(data.get("final"), dict) or not isinstance(data.get("finalState"), list):
|
||||
raise ValueError("Missing native series/final/finalState structure")
|
||||
if set(data["series"]) != set(metadata) | {"time"} or set(data["final"]) != set(metadata):
|
||||
raise ValueError("Series/final key sets do not match the supplied manifest")
|
||||
if len(data["finalState"]) != len(states) or not set(states) <= set(metadata):
|
||||
raise ValueError("State keys/order cannot be mapped from the supplied manifest")
|
||||
times = data["series"]["time"]
|
||||
if not isinstance(times, list) or not times:
|
||||
raise ValueError("No saved samples")
|
||||
for key, values in data["series"].items():
|
||||
if not isinstance(values, list) or len(values) != len(times):
|
||||
raise ValueError(f"Invalid sample length: {key}")
|
||||
for value in values:
|
||||
finite(value)
|
||||
for value in (*data["final"].values(), *data["finalState"]):
|
||||
finite(value)
|
||||
if any(a >= b for a, b in zip(times, times[1:])):
|
||||
raise ValueError("Sample times are not strictly increasing")
|
||||
if times[0] != start or times[-1] != stop or data.get("simulatedUntil") != stop:
|
||||
raise ValueError("Result does not span the requested complete interval")
|
||||
|
||||
|
||||
def input_observations(data, metadata, states, grid):
|
||||
series, times = data["series"], data["series"]["time"]
|
||||
fixed = set(grid)
|
||||
mapping = {t: i for i, t in enumerate(times)}
|
||||
extras = [i for i, t in enumerate(times) if t not in fixed]
|
||||
mechanical = [key for key, item in metadata.items() if item.get("scope") == "component"
|
||||
and item.get("quantity") in {"force", "length", "velocity", "acceleration"}]
|
||||
events = []
|
||||
for i in extras:
|
||||
indices = list(range(max(0, i - 1), min(len(times), i + 2)))
|
||||
neighborhood = {}
|
||||
for key, item in metadata.items():
|
||||
group = (item["quantity"], item["unit"])
|
||||
best = max(indices, key=lambda j: abs(series[key][j]))
|
||||
current = neighborhood.get(group)
|
||||
if current is None or abs(series[key][best]) > current["absolute"]:
|
||||
neighborhood[group] = {"quantity": group[0], "unit": group[1], "key": key,
|
||||
"absolute": abs(series[key][best]), "value": series[key][best], "time": times[best]}
|
||||
events.append({"sampleIndex": i, "time": times[i], "neighborSampleTimes": [times[j] for j in indices],
|
||||
"stateSamples": [{"time": times[j], "values": {key: series[key][j] for key in states}} for j in indices],
|
||||
"mechanicalSamples": [{"key": key, "unit": metadata[key]["unit"],
|
||||
"values": [series[key][j] for j in indices],
|
||||
"neighborhoodPeak": peak([series[key][j] for j in indices], [times[j] for j in indices])}
|
||||
for key in mechanical],
|
||||
"neighborhoodQuantityPeaks": list(neighborhood.values())})
|
||||
mass_keys = [key for key in states if key.rsplit(".", 1)[-1] in {"m", "m1", "m2"}
|
||||
and metadata[key]["quantity"] == "mass" and metadata[key]["unit"] == "kg"]
|
||||
conservation = None
|
||||
if mass_keys:
|
||||
totals = [math.fsum(series[key][i] for key in mass_keys) for i in range(len(times))]
|
||||
drift = [abs(value - totals[0]) for value in totals]
|
||||
worst = max(range(len(times)), key=drift.__getitem__)
|
||||
conservation = {"stateKeys": mass_keys, "uniqueMassStateCount": len(mass_keys), "initialKg": totals[0],
|
||||
"finalKg": totals[-1], "maxAbsoluteDriftKg": drift[worst], "worstTime": times[worst],
|
||||
"sampleCount": len(times), "includesOffGridSamples": True,
|
||||
"method": "math.fsum of unique manifest gas mass states; output aliases are not accumulated"}
|
||||
return {"metadata": {k: v for k, v in data.items() if k not in PAYLOAD},
|
||||
"sampleCount": len(times), "seriesVariableCount": len(metadata), "stateCount": len(states),
|
||||
"missingFixedGridTimes": [t for t in grid if t not in mapping],
|
||||
"excludedFromFixedGrid": [{"index": i, "time": times[i]} for i in extras],
|
||||
"extraSampleCount": len(extras), "reportedStateTransitions": data.get("stateTransitions"),
|
||||
"eventInterpretation": "Off-grid saved points are event candidates, not guaranteed event identities. An event on the fixed grid is not distinguishable from ordinary samples. Neighbors are nearest saved samples, not the true pre/post impact limits; a non-grid final endpoint can also be extra.",
|
||||
"events": events, "massConservation": conservation,
|
||||
"final": data["final"], "finalStateByKey": dict(zip(states, data["finalState"], strict=True)),
|
||||
"finalVsLastSeriesUnequalKeys": [key for key in metadata if data["final"][key] != series[key][-1]],
|
||||
"finalStateVsLastSeriesUnequalKeys": [key for key, value in zip(states, data["finalState"], strict=True) if value != series[key][-1]]}
|
||||
|
||||
|
||||
def aggregate(rows):
|
||||
groups = {}
|
||||
for row in rows:
|
||||
group = (row["quantity"], row["unit"])
|
||||
if group not in groups:
|
||||
groups[group] = {"quantity": group[0], "unit": group[1], "variableCount": 0,
|
||||
"sampleValues": 0, "maxAbsoluteError": -1., "norm": 0.}
|
||||
total = groups[group]
|
||||
total["variableCount"] += 1
|
||||
total["sampleValues"] += row["sampleCount"]
|
||||
total["norm"] = math.hypot(total["norm"], row["rmsError"] * math.sqrt(row["sampleCount"]))
|
||||
if row["maxAbsoluteError"] > total["maxAbsoluteError"]:
|
||||
total.update(maxAbsoluteError=row["maxAbsoluteError"], worstKey=row["key"], worstTime=row["worstTime"])
|
||||
for total in groups.values():
|
||||
total["rmsError"] = total.pop("norm") / math.sqrt(total["sampleValues"])
|
||||
return list(groups.values())
|
||||
|
||||
|
||||
def trajectory_comparison(left, right, metadata, states, grid, label):
|
||||
a_times, b_times = left["series"]["time"], right["series"]["time"]
|
||||
a_index, b_index = {t: i for i, t in enumerate(a_times)}, {t: i for i, t in enumerate(b_times)}
|
||||
common = [t for t in grid if t in a_index and t in b_index]
|
||||
if not common:
|
||||
raise ValueError(f"No exactly shared fixed-grid times: {label}")
|
||||
ai, bi = [a_index[t] for t in common], [b_index[t] for t in common]
|
||||
curves = []
|
||||
state_rows = []
|
||||
state_norms = [0.] * len(common)
|
||||
state_set = set(states)
|
||||
for key, item in metadata.items():
|
||||
av = [left["series"][key][i] for i in ai]
|
||||
bv = [right["series"][key][i] for i in bi]
|
||||
errors = [b - a for a, b in zip(av, bv, strict=True)]
|
||||
if not all(math.isfinite(e) for e in errors):
|
||||
raise ValueError(f"Difference exceeds binary64 range: {key}")
|
||||
worst = max(range(len(common)), key=lambda i: abs(errors[i]))
|
||||
row = {"key": key, "quantity": item["quantity"], "unit": item["unit"], "sampleCount": len(common),
|
||||
"maxAbsoluteError": abs(errors[worst]), "rmsError": rms(errors), "worstTime": common[worst],
|
||||
"leftValueAtWorst": av[worst], "rightValueAtWorst": bv[worst],
|
||||
"leftAllSavedPeak": peak(left["series"][key], a_times),
|
||||
"rightAllSavedPeak": peak(right["series"][key], b_times)}
|
||||
curves.append(row)
|
||||
if key in state_set:
|
||||
atol = float(state_absolute_tolerance(key))
|
||||
z = [abs(e) / (atol + RTOL * max(abs(a), abs(b))) for e, a, b in zip(errors, av, bv, strict=True)]
|
||||
at = max(range(len(z)), key=z.__getitem__)
|
||||
state_rows.append({"key": key, "unit": item["unit"], "atol": atol,
|
||||
"maxWeightedError": z[at], "rmsWeightedError": rms(z), "worstTime": common[at],
|
||||
"maxAbsoluteError": row["maxAbsoluteError"], "rmsError": row["rmsError"]})
|
||||
for i, value in enumerate(z):
|
||||
state_norms[i] = math.hypot(state_norms[i], value)
|
||||
wrms = [value / math.sqrt(len(states)) for value in state_norms]
|
||||
weighted_worst = max(state_rows, key=lambda row: row["maxWeightedError"])
|
||||
wrms_at = max(range(len(wrms)), key=wrms.__getitem__)
|
||||
final_rows = []
|
||||
for key, item in metadata.items():
|
||||
a, b = left["final"][key], right["final"][key]
|
||||
error = abs(b - a)
|
||||
final_rows.append({"key": key, "quantity": item["quantity"], "unit": item["unit"],
|
||||
"sampleCount": 1, "maxAbsoluteError": error, "rmsError": error,
|
||||
"worstTime": right["simulatedUntil"], "left": a, "right": b})
|
||||
terminal = []
|
||||
for key, a, b in zip(states, left["finalState"], right["finalState"], strict=True):
|
||||
atol = float(state_absolute_tolerance(key))
|
||||
terminal.append({"key": key, "unit": metadata[key]["unit"], "left": a, "right": b,
|
||||
"absoluteError": abs(b - a), "weightedError": abs(b - a) / (atol + RTOL * max(abs(a), abs(b)))})
|
||||
fixed = set(grid)
|
||||
a_extra, b_extra = [t for t in a_times if t not in fixed], [t for t in b_times if t not in fixed]
|
||||
return {"label": label, "comparedFixedGridTimes": common, "comparedSampleCount": len(common),
|
||||
"expectedFixedGridCount": len(grid), "allFixedGridTimesCompared": len(common) == len(grid),
|
||||
"missingFromLeft": [t for t in grid if t not in a_index], "missingFromRight": [t for t in grid if t not in b_index],
|
||||
"curves": curves, "quantityGroups": aggregate(curves),
|
||||
"stateErrors": {"formula": "abs(right-left)/(state_atol + 1e-8*max(abs(left),abs(right))), independently at each state/time",
|
||||
"interpretation": "Diagnostic normalization only. Local integration tolerances are not global trajectory acceptance thresholds.",
|
||||
"rows": state_rows, "maxWeightedError": weighted_worst["maxWeightedError"],
|
||||
"worstKey": weighted_worst["key"], "worstTime": weighted_worst["worstTime"],
|
||||
"wrmsAtEachComparedTime": wrms, "maxWrms": wrms[wrms_at], "maxWrmsTime": common[wrms_at]},
|
||||
"final": {"rows": final_rows, "quantityGroups": aggregate(final_rows)},
|
||||
"finalState": {"rows": terminal, "maxWeightedError": max(row["weightedError"] for row in terminal),
|
||||
"wrms": rms([row["weightedError"] for row in terminal])},
|
||||
"eventTimeDiagnostics": {"leftExtraTimes": a_extra, "rightExtraTimes": b_extra,
|
||||
"leftCount": len(a_extra), "rightCount": len(b_extra),
|
||||
"reportedTransitions": [left.get("stateTransitions"), right.get("stateTransitions")],
|
||||
"ordinalTimeDifferences": [{"ordinal": i + 1, "leftTime": a, "rightTime": b, "rightMinusLeftSeconds": b - a}
|
||||
for i, (a, b) in enumerate(zip(a_extra, b_extra, strict=True))] if len(a_extra) == len(b_extra) else None,
|
||||
"interpretation": "Equal-count ordinal differences are observations only, not verified physical event matching. Different counts are not paired. Event and neighbor values/peaks are in input observations; no time shifting or interpolation."}}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
parser.add_argument("--baseline", required=True, type=Path)
|
||||
parser.add_argument("--candidate", required=True, type=Path)
|
||||
parser.add_argument("--manifest", required=True, type=Path)
|
||||
parser.add_argument("--reference", type=Path)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
parser.add_argument("--start", type=float, default=0.)
|
||||
parser.add_argument("--stop", type=float, default=10.)
|
||||
parser.add_argument("--sample-step", type=float, default=.01)
|
||||
args = parser.parse_args()
|
||||
if not all(math.isfinite(v) for v in (args.start, args.stop, args.sample_step)) or not (args.stop > args.start and args.sample_step > 0):
|
||||
parser.error("Require finite increasing interval and positive sample step")
|
||||
if (args.stop - args.start) / args.sample_step > 1000000 or args.start + args.sample_step == args.start:
|
||||
parser.error("Invalid or excessive sampling grid")
|
||||
output = args.output.resolve()
|
||||
sources = [args.baseline, args.candidate, args.manifest] + ([args.reference] if args.reference else [])
|
||||
if output in {path.resolve() for path in sources}:
|
||||
parser.error("Output must not overwrite an input")
|
||||
report = {"schemaVersion": 1, "complete": False, "errors": [], "inputs": {}, "comparisons": {},
|
||||
"numericalAcceptance": "Not assessed: diagnostic errors only; no tolerance relaxation or automatic pass threshold.",
|
||||
"definitions": {"fixedGrid": "start + integer index * sampleStep; exact floating-point time membership, no interpolation",
|
||||
"quantityRms": "Pooled RMS of every compared value in that quantity/unit group; output aliases are included. Per-curve RMS is also reported.",
|
||||
"peaks": "All saved points including off-grid events. A sampled peak need not be the continuous-time peak.",
|
||||
"reference": "User-supplied reference; native JSON alone does not establish tighter effective tolerances or convergence. Both comparisons retain normalization rtol=1e-8.",
|
||||
"sharedManifest": "Caller must establish identical state order and physical output mapping for every input; one shared manifest is checked against all key sets and lengths."},
|
||||
"scriptSha256": sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
"stateToleranceSourceSha256": sha256((ROOT / 'app/simulation/native_codegen/tolerances.py').read_bytes()).hexdigest(),
|
||||
"normalizationRtol": RTOL, "grid": {"start": args.start, "stop": args.stop, "sampleStep": args.sample_step}}
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
manifest, identity = read_json(args.manifest)
|
||||
report["manifest"] = identity
|
||||
states = manifest["stateKeys"]
|
||||
variables = manifest["variables"]
|
||||
metadata = {row["key"]: row for row in variables}
|
||||
if not states or len(states) != len(set(states)) or len(metadata) != len(variables):
|
||||
raise ValueError("Manifest has empty/duplicate state keys or duplicate output keys")
|
||||
if not all(isinstance(row.get("quantity"), str) and isinstance(row.get("unit"), str) for row in variables):
|
||||
raise ValueError("Every manifest output requires quantity and unit metadata")
|
||||
report["stateKeys"] = states
|
||||
report["stateAbsoluteTolerances"] = {key: float(state_absolute_tolerance(key)) for key in states}
|
||||
grid = []
|
||||
i = 0
|
||||
while (t := args.start + i * args.sample_step) <= args.stop:
|
||||
grid.append(t)
|
||||
i += 1
|
||||
data = {}
|
||||
for label, path in [("baseline", args.baseline), ("candidate", args.candidate)] + ([("reference", args.reference)] if args.reference else []):
|
||||
data[label], identity = read_json(path)
|
||||
report["inputs"][label] = identity
|
||||
validate(data[label], metadata, states, args.start, args.stop)
|
||||
report["inputs"][label]["observations"] = input_observations(data[label], metadata, states, grid)
|
||||
report["comparisons"]["candidateVsBaseline"] = trajectory_comparison(data["baseline"], data["candidate"], metadata, states, grid, "candidate minus baseline")
|
||||
if args.reference:
|
||||
for label in ("baseline", "candidate"):
|
||||
report["comparisons"][label + "VsReference"] = trajectory_comparison(data["reference"], data[label], metadata, states, grid, label + " minus supplied reference")
|
||||
report["complete"] = True
|
||||
except (OSError, ValueError, KeyError, TypeError, OverflowError) as exc:
|
||||
report["errors"].append(f"{type(exc).__name__}: {exc}")
|
||||
output.write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"complete": report["complete"], "errors": report["errors"], "output": str(output),
|
||||
"comparedSamples": {key: value["comparedSampleCount"] for key, value in report["comparisons"].items()},
|
||||
"numericalAcceptance": report["numericalAcceptance"]}, ensure_ascii=False), flush=True)
|
||||
return 0 if report["complete"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -13,6 +13,9 @@ unchanged. Full output/state/counter equality is checked outside run timing.
|
||||
Inclusive durations are nested: only exclusiveSeconds may be added. Clock and
|
||||
bookkeeping overhead remain in measured totals; compare against the control.
|
||||
No property/pipe/libc allocation is inferred from this outer-only diagnostic.
|
||||
The production automatic Jacobian is used by default. --verify-jacobian enables
|
||||
full-matrix checking; those diagnostic timings are not ordinary production cost.
|
||||
Recorded --jacobian auto/verify options are translated; dense replay is rejected.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -32,7 +35,7 @@ ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
from app.simulation.native_codegen.build import LIBRARIES, toolchain
|
||||
|
||||
CATEGORIES = ["integration", "cvode_step", "rhs", "poll", "accept", "append", "dense_output", "dense_setup", "dense_solve"]
|
||||
CATEGORIES = ["integration", "cvode_step", "rhs", "poll", "accept", "append", "dense_output", "dense_setup", "dense_solve", "jacobian"]
|
||||
COUNTERS = ["rhs", "linear_rhs", "nonlinear_iterations", "nonlinear_failures"]
|
||||
PROFILE_HEADER = r'''
|
||||
#ifndef NATIVE_COMPUTE_PROFILE_H
|
||||
@@ -145,8 +148,8 @@ def instrument(native: Path) -> None:
|
||||
(native / "include/compute_profile.h").write_text(PROFILE_HEADER.replace("@CATEGORIES@", ", ".join("PROFILE_" + c.upper() for c in CATEGORIES)))
|
||||
(native / "runtime/compute_profile.c").write_text(PROFILE_SOURCE.replace("@NAMES@", ",".join(json.dumps(c) for c in CATEGORIES)))
|
||||
for filename, functions in {
|
||||
"common.c": {"native_rhs": "rhs", "native_poll": "poll", "native_accept": "accept", "native_append": "append"},
|
||||
"cvode_solver.c": {"native_bdf": "integration", "cv_dense": "dense_output"},
|
||||
"common.c": {"native_rhs": "rhs", "native_jacobian_rhs": "rhs", "native_poll": "poll", "native_accept": "accept", "native_append": "append"},
|
||||
"cvode_solver.c": {"native_bdf": "integration", "cv_dense": "dense_output", "cv_jacobian": "jacobian"},
|
||||
"rk45.c": {"native_rk45": "integration"},
|
||||
}.items():
|
||||
path = native / "runtime" / filename
|
||||
@@ -168,7 +171,13 @@ def instrument(native: Path) -> None:
|
||||
path.write_text(text)
|
||||
|
||||
|
||||
def runtime_arguments(stages: Path | None) -> list[str]:
|
||||
def runtime_arguments(stages: Path | None, verify_jacobian: bool = False) -> list[str]:
|
||||
"""Replay numerical options using the production Jacobian only.
|
||||
|
||||
Historical auto becomes the default and verify becomes --verify-jacobian.
|
||||
A historical dense request must run with its frozen historical tool/runtime;
|
||||
silently replaying it with today's automatic algorithm would fake a baseline.
|
||||
"""
|
||||
if stages:
|
||||
original = json.loads(stages.read_text())["process"]["command"]
|
||||
args = original[1:]
|
||||
@@ -178,18 +187,30 @@ def runtime_arguments(stages: Path | None) -> list[str]:
|
||||
value_options = {"--method", "--start", "--stop", "--sample-step", "--max-step", "--rtol", "--timeout"}
|
||||
while index < len(args):
|
||||
key = args[index]
|
||||
if key == "--verify-jacobian":
|
||||
verify_jacobian = True; index += 1; continue
|
||||
if key == "--solve-only":
|
||||
safe.append(key); index += 1; continue
|
||||
if key not in value_options | {"--output", "--result-index", "--cancel-file"} or index + 1 >= len(args):
|
||||
if key not in value_options | {"--jacobian", "--output", "--result-index", "--cancel-file"} or index + 1 >= len(args):
|
||||
raise RuntimeError(f"Unsupported replay argument: {key}")
|
||||
if key in value_options:
|
||||
if key == "--jacobian":
|
||||
recorded = args[index + 1]
|
||||
if recorded == "dense":
|
||||
raise RuntimeError("Historical --jacobian dense requires the frozen historical executable and tool; current production has no legacy strategy selector")
|
||||
if recorded not in {"auto", "verify"}:
|
||||
raise RuntimeError(f"Unsupported historical Jacobian mode: {recorded}")
|
||||
verify_jacobian = verify_jacobian or recorded == "verify"
|
||||
elif key in value_options:
|
||||
safe.extend(args[index:index + 2])
|
||||
index += 2
|
||||
if verify_jacobian:
|
||||
safe.append("--verify-jacobian")
|
||||
return safe
|
||||
|
||||
|
||||
def prepare(args: argparse.Namespace) -> dict:
|
||||
cache, output = args.cache_dir.resolve(), args.output_dir.resolve()
|
||||
numerical_arguments = runtime_arguments(args.request_stages, args.verify_jacobian)
|
||||
if not output.is_relative_to(ROOT / "test"):
|
||||
raise RuntimeError("Diagnostic output must be in the repository's ignored test/ directory")
|
||||
manifest = json.loads((cache / "manifest.json").read_text())
|
||||
@@ -223,7 +244,7 @@ def prepare(args: argparse.Namespace) -> dict:
|
||||
shutil.copy2(cache / name, output / name)
|
||||
instrument(native)
|
||||
command = [compiler, *manifest["compilerFlags"], "-I", str(output), "-I", str(native / "include"), "-I", str(sundials / "include"), str(output / "model.c"), *map(str, sorted(native.rglob("*.c"))), "-Wl,--start-group", *map(str, libraries), "-Wl,--end-group", "-lm", "-o", str(output / "profiled-model")]
|
||||
prepared = {"controlExecutable": str(cache / "model"), "profiledExecutable": str(output / "profiled-model"), "buildCommand": command, "runtimeArguments": runtime_arguments(args.request_stages), "cacheDir": str(cache), "cachedMainDifference": differences, "stateCount": len(manifest["stateKeys"]), "modelSha256": digest(output / "model.c"), "compiler": compiler_version, "warmups": args.warmups, "repeats": args.repeats, "scope": "Inclusive times overlap. Exclusive scope times partition integration including instrumentation overhead. No component or libc sub-cost inference.", "sourceHashes": {str(p.relative_to(output)): digest(p) for p in sorted(native.rglob("*")) if p.is_file()}}
|
||||
prepared = {"controlExecutable": str(cache / "model"), "profiledExecutable": str(output / "profiled-model"), "buildCommand": command, "runtimeArguments": numerical_arguments, "verifyJacobian": "--verify-jacobian" in numerical_arguments, "algorithm": "production-automatic", "cacheDir": str(cache), "cachedMainDifference": differences, "stateCount": len(manifest["stateKeys"]), "modelSha256": digest(output / "model.c"), "compiler": compiler_version, "warmups": args.warmups, "repeats": args.repeats, "scope": "Inclusive times overlap. Exclusive scope times partition integration including instrumentation overhead. No component or libc sub-cost inference.", "sourceHashes": {str(p.relative_to(output)): digest(p) for p in sorted(native.rglob("*")) if p.is_file()}}
|
||||
write_json(output / "prepared.json", prepared)
|
||||
return prepared
|
||||
|
||||
@@ -274,10 +295,11 @@ def execute(args: argparse.Namespace, prepared: dict) -> None:
|
||||
write_json(run / "parity-failure.json", mismatches)
|
||||
raise RuntimeError(f"Numerical/counter parity failed: {mismatches}")
|
||||
row = {"variant": variant, "run": label, "warmup": index < 0, "processWallSeconds": wall, "solveSeconds": result["solveSeconds"], "solveCpuSeconds": result["solveCpuSeconds"], "fullParity": True, "resultBytes": result_path.stat().st_size, "nfev": result["nfev"], "njev": result["njev"], "nlu": result["nlu"], "acceptedSteps": result["acceptedSteps"], "solverStarts": result["solverStarts"]}
|
||||
row.update({key: result[key] for key in ("jacobianMode", "jacobianRhsCalls", "jacobianColoredEvals", "jacobianFallbacks", "jacobianChecks", "jacobianMismatches", "cvodeRhsCalls", "cvodeLinearRhsCalls") if key in result})
|
||||
if variant == "profiled":
|
||||
profile = json.loads(profile_path.read_text())
|
||||
counters = profile["cvodeCounters"]
|
||||
checks = {"counterReadsSucceeded": profile["counterErrors"] == 0, "rhsCountMatches": counters["rhs"] + counters["linear_rhs"] == result["nfev"], "linearRhsEqualsJacobianCountTimesStates": counters["linear_rhs"] == result["njev"] * prepared["stateCount"], "rhsClockCountMatches": profile["scopes"]["integration"]["rhs"]["calls"] == result["nfev"]}
|
||||
checks = {"counterReadsSucceeded": profile["counterErrors"] == 0, "rhsCountMatches": counters["rhs"] + counters["linear_rhs"] + result.get("jacobianRhsCalls", 0) == result["nfev"], "defaultLinearRhsEqualsJacobianCountTimesStates": (counters["linear_rhs"] == result["njev"] * prepared["stateCount"] if result.get("jacobianMode") == "dense-difference" and not result.get("jacobianRhsCalls", 0) else None), "rhsClockCountMatches": profile["scopes"]["integration"]["rhs"]["calls"] == result["nfev"]}
|
||||
row["profile"] = profile
|
||||
row["counterChecks"] = checks
|
||||
if not checks["counterReadsSucceeded"] or not checks["rhsClockCountMatches"] or (result["method"] == "BDF" and not checks["rhsCountMatches"]):
|
||||
@@ -288,7 +310,7 @@ def execute(args: argparse.Namespace, prepared: dict) -> None:
|
||||
print(f"{variant}/{label}: solve={row['solveSeconds']:.6f}s wall={wall:.6f}s parity=true", flush=True)
|
||||
medians = {variant: {key: statistics.median(row[key] for row in rows if row["variant"] == variant and not row["warmup"]) for key in ("processWallSeconds", "solveSeconds", "solveCpuSeconds")} for variant in ("control", "profiled")}
|
||||
overhead = {key: medians["profiled"][key] / medians["control"][key] - 1 for key in medians["control"]}
|
||||
write_json(output / "summary.json", {"prepared": prepared, "runs": rows, "medians": medians, "instrumentationOverheadFraction": overhead, "allFullParity": True, "interpretation": "CVODE linear_rhs counts finite-difference RHS calls independently of model nfev. Its multiplication by stateCount is checked, not assumed. RHS time includes all model work; no Jacobian-specific RHS time is inferred. cvode_step.exclusiveSeconds excludes nested RHS, polling and Dense ops; integration.exclusiveSeconds is outer setup/loop/cleanup work. Dense setup covers the original factorization operation; dense_solve covers the original triangular solve operation. Clock/bookkeeping costs remain in all measured totals. Production control may retain its pre-existing sparse main clocks; cachedMainDifference records this."})
|
||||
write_json(output / "summary.json", {"prepared": prepared, "runs": rows, "medians": medians, "instrumentationOverheadFraction": overhead, "allFullParity": True, "interpretation": "CVODE linear_rhs counts only its built-in finite-difference calls. Custom jacobianRhsCalls are counted separately and included in nfev reconciliation. State-count multiplication applies only to the default callback. The custom jacobian scope includes its canonical base/probe RHS work; these inclusive durations overlap RHS totals. cvode_step.exclusiveSeconds excludes nested RHS, polling and Dense ops; integration.exclusiveSeconds is outer setup/loop/cleanup work. Dense setup covers the original factorization operation; dense_solve covers the original triangular solve operation. Clock/bookkeeping costs remain in all measured totals. Production control may retain its pre-existing sparse main clocks; cachedMainDifference records this."})
|
||||
print(f"Summary: {output / 'summary.json'}", flush=True)
|
||||
|
||||
|
||||
@@ -297,10 +319,13 @@ def main() -> None:
|
||||
parser.add_argument("--cache-dir", required=True, type=Path)
|
||||
parser.add_argument("--request-stages", type=Path)
|
||||
parser.add_argument("--output-dir", required=True, type=Path)
|
||||
parser.add_argument("--verify-jacobian", action="store_true", help="Enable full-matrix diagnostic checks in both control/profiled runs; default uses production automatic Jacobian")
|
||||
parser.add_argument("--run", action="store_true", help="Build and run serial warmups/repeats; default only prepares")
|
||||
parser.add_argument("--warmups", type=int, default=1)
|
||||
parser.add_argument("--repeats", type=int, default=3)
|
||||
parser.add_argument("--process-timeout", type=float, default=360)
|
||||
if any(arg == "--jacobian" or arg.startswith("--jacobian=") for arg in sys.argv[1:]):
|
||||
parser.error("--jacobian strategy selection was removed; use the production default or --verify-jacobian. Dense requests require a frozen historical version (benchmark only: --baseline-executable).")
|
||||
args = parser.parse_args()
|
||||
if args.warmups < 0 or args.repeats < 1:
|
||||
parser.error("warmups must be nonnegative and repeats positive")
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Summarize Jacobian timing metadata without opening results or CSV files.
|
||||
|
||||
python3 tests/manual/summarize_jacobian_cost.py --root test/jacobian-20260911
|
||||
|
||||
Requires four complete browser groups (one warmup and three formal runs each)
|
||||
plus the standalone native benchmark. Writes ROOT/cost-summary.json by default.
|
||||
Incomplete evidence overwrites the destination with complete:false and exits 2.
|
||||
Numerical differences are recorded, never interpreted as numerical acceptance.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from statistics import median
|
||||
from typing import Any
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
GROUPS = {
|
||||
"baseline": ("control", None),
|
||||
"optimized": ("control", None),
|
||||
"baseline-profiled": ("profiled", "baseline-source/profiled-backend/requests"),
|
||||
"optimized-profiled": ("profiled", "backend-optimized-profiled/requests"),
|
||||
}
|
||||
GOALS = {"ready": "clickToReadyDomMs", "saved_observed": "clickToIndexedDbObservedMs",
|
||||
"csv_download_saved": "csvClickToDownloadSavedMs"}
|
||||
COUNTERS = ("nfev", "acceptedSteps", "rejectedSteps", "stateTransitions", "solverStarts", "njev", "nlu")
|
||||
JAC_COUNTERS = ("jacobianRhsCalls", "jacobianColoredEvals", "jacobianFallbacks", "jacobianChecks",
|
||||
"jacobianMismatches", "cvodeRhsCalls", "cvodeLinearRhsCalls")
|
||||
IDENTITY = ("backend", "method", "solver", "sundialsVersion", "simulatedUntil", "maxAcceptedStep")
|
||||
NATIVE_TIMES = ("solveSeconds", "solveCpuSeconds", "processWallSeconds", "buildSeconds")
|
||||
C_WALL = ("argumentPreparationSeconds", "initializationSeconds", "integrationSeconds",
|
||||
"finalSampleAndStatusSeconds", "projectionSeconds", "jsonWriteSeconds", "mainTotalSeconds")
|
||||
DEFINITIONS = {
|
||||
"scope": "Jacobian experiment only; metadata summaries, no result arrays or CSV contents are opened. complete means timing evidence is complete, not numerical acceptance.",
|
||||
"statistics": "Formal runs and warmups are separate. Each metric reports n/min/median/max and missing count. A missing baseline Jacobian field means unavailable, not zero.",
|
||||
"browserComparisons": "The separately collected control groups provide end-to-end changes: reduction = 100*(baseline median-optimized median)/baseline median. Run ordinals are not paired trials. Warmups are excluded.",
|
||||
"nativeComparisons": "The native benchmark alternated serial dense/auto pairs with one executable. Its paired statistics and ratio of group medians are kept separately; neither is a browser estimate.",
|
||||
"instrumentation": "Profiled/control group-median differences are observational diagnostics, not isolated instrumentation overhead. Separate collection, scheduling and thermal variation can produce negative increments.",
|
||||
"ready": "Click to DOM-observed completion, not GPU completion. Paint opportunity is separately recorded.",
|
||||
"saved": "Control comparisons use IndexedDB pointer observation, including polling/scheduling latency. Exact instrumented commit timing is a separate metric.",
|
||||
"csv": "CSV click to Playwright download save completion includes automation and filesystem work. It is a separate action after solve, not another segment of click-to-ready.",
|
||||
"backend": "Spans are inclusive wall intervals on the HTTP request axis. Parent/child intervals are retained; same-name spans are summed within a run. Stage percentages use that run's own HTTP duration before aggregation.",
|
||||
"c": "C integration includes solver setup/work, events and sampling. Projection and write are inside C main. CPU and wall are distinct; RHS/Jacobian counts are not CPU shares. No process-minus-solve estimate is named output-write time.",
|
||||
"process": "Standalone process wall is subprocess creation through reap. Browser native.processWallSeconds includes Python result reading after exit; backend observed process lifetime is a separate span including spawn/exit-observation latency.",
|
||||
"overlap": "Browser receive overlaps backend execution/send; parse/decode are within reception. Render, persistence and other tasks can overlap. C main is inside process, which is inside orchestration/worker/HTTP. Never add overlapping stages or stage medians.",
|
||||
"network": "ASGI send-await time and browser outstanding-read time are not pure network measurements.",
|
||||
"cache": "Cache-hit false identifies a cold build; a warmup label alone does not. Formal browser rows must hit cache. Warmup/cold build costs are retained separately. File writes do not imply fsync.",
|
||||
"numerics": "Same input/settings and payload dimensions do not prove curve parity. Different trajectories, event times and work counts are expected between dense and experimental auto; numerical acceptance requires the separate trajectory/convergence review.",
|
||||
"portability": "Artifact paths are relative to this experiment root; repo input paths use repo-relative notation. Only recorded metadata hashes are propagated, not independently rehashed result payloads.",
|
||||
}
|
||||
|
||||
|
||||
def require(condition: bool, message: str) -> None:
|
||||
if not condition:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def number(value: Any) -> bool:
|
||||
return type(value) in (int, float) and math.isfinite(value)
|
||||
|
||||
|
||||
def statistics(values: list[Any]) -> dict:
|
||||
present = [v for v in values if number(v)]
|
||||
require(all(v is None or number(v) for v in values), "Invalid metric value")
|
||||
return {"n": len(present), "missing": len(values) - len(present),
|
||||
"min": min(present) if present else None, "median": median(present) if present else None,
|
||||
"max": max(present) if present else None}
|
||||
|
||||
|
||||
def field(data: dict, name: str, context: str, positive: bool = False) -> float:
|
||||
value = data.get(name)
|
||||
require(number(value) and (value > 0 if positive else value >= 0), f"{context}: invalid {name}")
|
||||
return value
|
||||
|
||||
|
||||
def select(data: dict, keys: tuple | list) -> dict:
|
||||
return {key: data.get(key) for key in keys}
|
||||
|
||||
|
||||
def metrics_summary(rows: list[dict], key: str = "metrics") -> dict:
|
||||
names = sorted({name for row in rows for name in row[key]})
|
||||
return {name: statistics([row[key].get(name) for row in rows]) for name in names}
|
||||
|
||||
|
||||
def ratio(before: dict, after: dict) -> dict:
|
||||
old, new = before["median"], after["median"]
|
||||
require(number(old) and number(new) and old > 0 and new > 0, "Missing comparison medians")
|
||||
return {"statistic": "ratio_of_group_medians", "baseline": before, "optimized": after,
|
||||
"saved": old - new, "durationReductionPercent": (old - new) / old * 100,
|
||||
"speedupRatio": old / new}
|
||||
|
||||
|
||||
class Summary:
|
||||
def __init__(self, root: Path):
|
||||
self.root = root.resolve()
|
||||
self.sources: dict[str, dict] = {}
|
||||
self.ids: set[str] = set()
|
||||
self.metric_definitions: dict[str, dict] = {}
|
||||
|
||||
def read(self, relative: str) -> dict:
|
||||
path = (self.root / relative).resolve()
|
||||
require(path.is_relative_to(self.root), f"Metadata path escapes root: {relative}")
|
||||
require(path.is_file(), f"Incomplete experiment: missing {relative}")
|
||||
require(path.stat().st_size <= 4 * 1024 * 1024, f"Refusing large metadata input: {relative}")
|
||||
raw = path.read_bytes()
|
||||
data = json.loads(raw, parse_constant=lambda token: (_ for _ in ()).throw(ValueError(token)))
|
||||
require(isinstance(data, dict), f"Expected metadata object: {relative}")
|
||||
self.sources[relative] = {"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}
|
||||
return data
|
||||
|
||||
def metric(self, row: dict, domain: str, name: str, value: Any, unit: str, parent: str | None = None) -> None:
|
||||
require(value is None or number(value), f"Invalid {domain}.{name}")
|
||||
key = f"{domain}.{name}"
|
||||
row["metrics"][key] = value
|
||||
self.metric_definitions[key] = {"unit": unit, "parent": parent,
|
||||
"additive": False, "inclusiveOrOverlapping": True}
|
||||
|
||||
def native_fields(self, row: dict, data: dict, context: str, *, browser: bool) -> dict:
|
||||
require(data.get("success") is True and data.get("status") == "completed", f"{context}: simulation failed")
|
||||
for key in IDENTITY:
|
||||
require(data.get(key) is not None, f"{context}: missing {key}")
|
||||
require(data.get("simulatedUntil") == 10, f"{context}: incomplete simulation endpoint")
|
||||
for key in COUNTERS:
|
||||
require(type(data.get(key)) is int and data[key] >= 0, f"{context}: invalid count {key}")
|
||||
for key in (*COUNTERS, *JAC_COUNTERS):
|
||||
value = data.get(key)
|
||||
require(value is None or type(value) is int and value >= 0, f"{context}: invalid counter {key}")
|
||||
self.metric(row, "native", key, value, "count")
|
||||
for key in NATIVE_TIMES if browser else NATIVE_TIMES[:-1]:
|
||||
self.metric(row, "native", key, field(data, key, context), "CPU_s" if "Cpu" in key else "s")
|
||||
self.metric(row, "native", "maxAcceptedStep", field(data, "maxAcceptedStep", context), "simulation_s")
|
||||
if all(data.get(k) is not None for k in ("nfev", "cvodeRhsCalls", "cvodeLinearRhsCalls", "jacobianRhsCalls")):
|
||||
require(data["nfev"] == sum(data[k] for k in ("cvodeRhsCalls", "cvodeLinearRhsCalls", "jacobianRhsCalls")),
|
||||
f"{context}: RHS accounting mismatch")
|
||||
return select(data, [*IDENTITY, *COUNTERS, *JAC_COUNTERS, *NATIVE_TIMES, "jacobianMode", "buildKey", "cacheHit"])
|
||||
|
||||
def backend(self, row: dict, relative: str) -> dict:
|
||||
data = self.read(relative)
|
||||
require(data.get("id") == row["simulationId"] and data.get("httpStatus") == 200, f"{relative}: request ID/status mismatch")
|
||||
for key, value in row["native"].items():
|
||||
require(data.get("native", {}).get(key) == value, f"{relative}: browser/backend native {key} mismatch")
|
||||
require(data.get("sampleCount") == row["sampleCount"], f"{relative}: sample count mismatch")
|
||||
http = field(data, "httpTotalSeconds", relative, True) * 1000
|
||||
self.metric(row, "backend", "httpTotalMs", http, "ms")
|
||||
totals: dict[str, float] = {}
|
||||
spans = data.get("spans", [])
|
||||
require(bool(spans), f"{relative}: missing spans")
|
||||
annotated = []
|
||||
for index, span in enumerate(spans):
|
||||
start, end = field(span, "startMs", relative), field(span, "endMs", relative)
|
||||
require(start <= end <= http + 1e-5, f"{relative}: span outside HTTP interval")
|
||||
name = span["name"]
|
||||
totals[name] = totals.get(name, 0) + end - start
|
||||
parents = [(other["endMs"] - other["startMs"], j, other["name"]) for j, other in enumerate(spans)
|
||||
if j != index and other["startMs"] <= start and end <= other["endMs"]
|
||||
and (other["startMs"] < start or end < other["endMs"])]
|
||||
parent = min(parents)[2] if parents else "httpTotalMs"
|
||||
annotated.append({"name": name, "startMs": start, "endMs": end, "parent": parent})
|
||||
for name, value in totals.items():
|
||||
self.metric(row, "backendSpan", name, value, "ms", "backend.httpTotalMs (percentage denominator)")
|
||||
self.metric(row, "percentOfHttp", name, value / http * 100, "%", "backend.httpTotalMs")
|
||||
for name in ("requestBodyCompleteMs", "responseHeadersMs", "largeResultBodySendStartMs", "responseBodyCompleteMs"):
|
||||
self.metric(row, "backendPosition", name, data.get(name), "ms_from_request_start")
|
||||
self.metric(row, "backend", "responseSendAwaitSeconds", field(data, "responseSendAwaitSeconds", relative), "s", "backend.httpTotalMs")
|
||||
for name in ("responseBodyBytes", "rawSeriesBytes", "xmlBytes"):
|
||||
self.metric(row, "backend", name, field(data, name, relative, True), "bytes")
|
||||
stages = data.get("nativeStages", {})
|
||||
main = field(stages, "mainTotalSeconds", relative, True)
|
||||
for name in C_WALL:
|
||||
value = field(stages, name, relative)
|
||||
self.metric(row, "cWall", name, value, "s", None if name == "mainTotalSeconds" else "cWall.mainTotalSeconds")
|
||||
if name != "mainTotalSeconds":
|
||||
self.metric(row, "percentOfCMain", name, value / main * 100, "%", "cWall.mainTotalSeconds")
|
||||
for name in ("projectionCpuSeconds", "jsonWriteCpuSeconds"):
|
||||
self.metric(row, "cCpu", name, field(stages, name, relative), "CPU_s")
|
||||
process = data.get("process", {})
|
||||
require(process.get("exitCode") == 0, f"{relative}: child failed")
|
||||
for name in ("childrenUserCpuSeconds", "childrenSystemCpuSeconds"):
|
||||
self.metric(row, "process", name, field(process, name, relative), "CPU_s")
|
||||
for name, phase in data.get("existingPerformance", {}).get("phases", {}).items():
|
||||
self.metric(row, "backendExisting", name, field(phase, "inclusiveNs", relative) / 1e6, "ms", "backend.httpTotalMs")
|
||||
# Store only portable command flags; outputs and temporary filesystem paths are not needed.
|
||||
command = process.get("command", [])
|
||||
flags = {name: command[command.index(name) + 1] for name in ("--method", "--jacobian", "--start", "--stop", "--sample-step", "--max-step", "--rtol", "--timeout") if name in command}
|
||||
return {"source": relative, "simulationId": data["id"], "xmlSha256": data.get("xmlSha256"),
|
||||
"httpStatus": data["httpStatus"], "spans": annotated, "commandFlags": flags,
|
||||
"build": select(data.get("build", {}), ["cacheHit", "buildKey", "reportedSeconds"])}
|
||||
|
||||
def browser_group(self, name: str, mode: str, backend_root: str | None) -> dict:
|
||||
relative = f"browser-{name}/summary.json"
|
||||
data = self.read(relative)
|
||||
require(data.get("errors") == [], f"{name}: browser errors or missing errors field")
|
||||
rows = data.get("rows", [])
|
||||
require(len(rows) == 4 and sorted(r.get("run", -1) for r in rows) == [0, 1, 2, 3], f"Incomplete {name}: expected warmup 1 + formal 3")
|
||||
runs = []
|
||||
for source in sorted(rows, key=lambda r: r["run"]):
|
||||
context = f"{name}/{source['run']}"
|
||||
require(source.get("mode") == mode and source.get("deep") is False, f"{context}: instrumentation mode mismatch")
|
||||
require(source.get("warmup") is (source["run"] == 0), f"{context}: warmup mismatch")
|
||||
sid = source.get("simulationId")
|
||||
require(isinstance(sid, str) and sid and sid not in self.ids and Path(sid).name == sid, f"{context}: missing/duplicate/invalid ID")
|
||||
self.ids.add(sid)
|
||||
row = {"run": source["run"], "warmup": source["warmup"], "simulationId": sid, "metrics": {}}
|
||||
row["native"] = self.native_fields(row, source.get("native", {}), context, browser=True)
|
||||
require(type(row["native"]["cacheHit"]) is bool, f"{context}: missing cache hit evidence")
|
||||
require(source["warmup"] or row["native"]["cacheHit"], f"{context}: formal run contains cold build")
|
||||
require(source.get("restoredIdentical") is True, f"{context}: restore mismatch")
|
||||
for goal in GOALS.values():
|
||||
field(source, goal, context, True)
|
||||
for key, value in source.items():
|
||||
if key.endswith(("Ms", "Bytes")) or key == "streamReadCount":
|
||||
self.metric(row, "frontend", key, value, "bytes" if key.endswith("Bytes") else "count" if key == "streamReadCount" else "ms")
|
||||
for key in ("sampleCount", "variableCount"):
|
||||
row[key] = field(source, key, context, True)
|
||||
row.update(select(source, ["resultSha256", "numericalResultSha256", "csvSha256", "restoredIdentical"]))
|
||||
row["integration"] = select(source.get("integration", {}), ["method", "rtol"])
|
||||
require(row["integration"] == {"method": "BDF", "rtol": 1e-8}, f"{context}: method/rtol changed")
|
||||
row["backend"] = self.backend(row, f"{backend_root}/{sid}/stages.json") if backend_root else None
|
||||
runs.append(row)
|
||||
formal = [r for r in runs if not r["warmup"]]
|
||||
warmup = [r for r in runs if r["warmup"]]
|
||||
return {"source": relative, "mode": mode, **select(data, ["inputSha256", "buildAssetSetSha256", "browser", "node", "scriptSha256"]),
|
||||
"formal": metrics_summary(formal), "warmup": metrics_summary(warmup), "runs": runs,
|
||||
"cache": {"formalHits": sum(r["native"]["cacheHit"] for r in formal), "formalCount": len(formal),
|
||||
"coldRuns": [{"run": r["run"], "warmup": r["warmup"], "buildSeconds": r["native"]["buildSeconds"]}
|
||||
for r in runs if not r["native"]["cacheHit"]]}}
|
||||
|
||||
def native_benchmark(self) -> dict:
|
||||
data = self.read("benchmark/summary.json")
|
||||
require(data.get("complete") is True and data.get("errors") == [], "Native benchmark incomplete or execution errors")
|
||||
prepared = data["prepared"]
|
||||
settings = prepared.get("settings", {})
|
||||
require(all(settings.get(k) == v for k, v in {"method": "BDF", "rtol": 1e-8, "t_start": 0, "t_stop": 10}.items()),
|
||||
"Native method/rtol/time settings changed")
|
||||
require(prepared.get("sampleStep") == 0.01, "Native fixed sample interval changed")
|
||||
require(prepared.get("warmupsPerMode") == 1 and prepared.get("repeatsPerMode") == 3, "Native run count configuration changed")
|
||||
runs = []
|
||||
for source in data.get("rows", []):
|
||||
require(source.get("completed") is True and source.get("exitCode") == 0, "Native run failed")
|
||||
row = {**select(source, ["mode", "label", "pair", "warmup", "diagnostic", "includedInStatistics", "resultBytes", "resultSha256"]), "metrics": {}}
|
||||
row["native"] = self.native_fields(row, source, f"native/{source['mode']}/{source['label']}", browser=False)
|
||||
parts = Path(source["directory"]).parts
|
||||
require("benchmark" in parts, "Native artifact directory lacks benchmark prefix")
|
||||
row["artifactDirectory"] = Path(*parts[parts.index("benchmark"):]).as_posix()
|
||||
row["payloadMetadata"] = source.get("resultValidation")
|
||||
runs.append(row)
|
||||
groups = {}
|
||||
for mode in ("dense", "auto"):
|
||||
formal = [r for r in runs if r["mode"] == mode and r["includedInStatistics"]]
|
||||
warmup = [r for r in runs if r["mode"] == mode and r["warmup"]]
|
||||
require(len(formal) == 3 and len(warmup) == 1, f"Incomplete native {mode} repetitions")
|
||||
groups[mode] = {"formal": metrics_summary(formal), "warmup": metrics_summary(warmup)}
|
||||
verify = [r for r in runs if r["mode"] == "verify"]
|
||||
if prepared.get("verifyRequested"):
|
||||
require(len(verify) == 1 and verify[0]["diagnostic"] and not verify[0]["includedInStatistics"], "Missing separate verify run")
|
||||
return {"source": "benchmark/summary.json", "inputSha256": prepared.get("inputSha256"),
|
||||
"xmlSha256": prepared.get("xmlSha256"), "settings": prepared.get("settings"),
|
||||
"sampleStep": prepared.get("sampleStep"), "stateCount": prepared.get("stateCount"),
|
||||
"timingContract": prepared.get("timingContract"), "environment": prepared.get("environment"),
|
||||
"preparationSeconds": prepared.get("preparationSeconds"),
|
||||
"build": select(data.get("build", {}), ["buildKey", "cacheHit", "seconds"]),
|
||||
"groups": groups, "runs": runs, "speedComparison": data.get("speedComparison"),
|
||||
"strictComparisonPassed": data.get("passed"), "allPayloadBitsEqual": data.get("allPayloadBitsEqual"),
|
||||
"numericalAcceptance": data.get("numericalAcceptance")}
|
||||
|
||||
def compute_profile(self, native: dict) -> dict:
|
||||
relative = "native-compute-profile/summary.json"
|
||||
if not (self.root / relative).exists():
|
||||
return {"available": False, "source": relative,
|
||||
"reason": "Optional compute profile metadata has not been generated."}
|
||||
data = self.read(relative)
|
||||
require(data.get("allFullParity") is True, "Compute profile payload/counter parity not confirmed")
|
||||
prepared = data.get("prepared", {})
|
||||
require(prepared.get("warmups") == 1 and prepared.get("repeats") == 3, "Compute profile run count changed")
|
||||
require(Path(prepared.get("controlExecutable", "")).parent.name == native["build"]["buildKey"],
|
||||
"Compute profile uses a different native control build")
|
||||
runtime = prepared.get("runtimeArguments", [])
|
||||
require("--verify-jacobian" not in runtime and not prepared.get("verifyJacobian"),
|
||||
"Verification compute profile is diagnostic-only, not ordinary production cost")
|
||||
if "--jacobian" in runtime:
|
||||
require(runtime[runtime.index("--jacobian") + 1] == "auto", "Historical compute profile is not auto")
|
||||
else:
|
||||
require(prepared.get("algorithm") == "production-automatic" or
|
||||
(bool(data.get("runs")) and all(r.get("jacobianMode") == "colored-difference" for r in data["runs"])),
|
||||
"Compute profile lacks evidence of the production automatic algorithm")
|
||||
require("--rtol" in runtime and float(runtime[runtime.index("--rtol") + 1]) == 1e-8, "Compute profile rtol changed")
|
||||
rows = []
|
||||
for source in data.get("runs", []):
|
||||
require(source.get("fullParity") is True, "Compute profile run parity failed")
|
||||
row = {**select(source, ["variant", "run", "warmup", "fullParity"]), "metrics": {}}
|
||||
for key in ("processWallSeconds", "solveSeconds", "solveCpuSeconds"):
|
||||
self.metric(row, "computeProfile", key, field(source, key, relative, True),
|
||||
"CPU_s" if "Cpu" in key else "s")
|
||||
for key in ("nfev", "njev", "nlu", "acceptedSteps", "solverStarts"):
|
||||
self.metric(row, "computeCounter", key, field(source, key, relative), "count")
|
||||
if source.get("variant") == "profiled":
|
||||
profile = source.get("profile", {})
|
||||
require(profile.get("counterErrors") == 0, "Compute profile counter read failed")
|
||||
require(all(v is not False for v in source.get("counterChecks", {}).values()), "Compute profile counter check failed")
|
||||
for key, value in profile.get("cvodeCounters", {}).items():
|
||||
self.metric(row, "cvodeCounter", key, value, "count")
|
||||
scopes = profile.get("scopes", {})
|
||||
integration = scopes.get("integration", {})
|
||||
total = field(integration.get("integration", {}), "inclusiveSeconds", relative, True)
|
||||
for region, region_scopes in scopes.items():
|
||||
for scope, values in region_scopes.items():
|
||||
for kind in ("calls", "inclusiveSeconds", "exclusiveSeconds"):
|
||||
self.metric(row, f"scope.{region}.{scope}", kind, field(values, kind, relative),
|
||||
"count" if kind == "calls" else "s")
|
||||
if region == "integration":
|
||||
for kind in ("inclusiveSeconds", "exclusiveSeconds"):
|
||||
self.metric(row, f"scopePercentOfIntegration.{scope}", kind,
|
||||
values[kind] / total * 100, "%", "scope.integration.integration.inclusiveSeconds")
|
||||
exclusive_sum = math.fsum(s["exclusiveSeconds"] for s in integration.values())
|
||||
residual = total - exclusive_sum
|
||||
require(abs(residual) <= max(1e-9, total * 1e-9), "Compute profile scopes do not partition integration")
|
||||
row["exclusivePartition"] = {"integrationSeconds": total, "exclusiveSumSeconds": exclusive_sum,
|
||||
"residualSeconds": residual, "percentSum": exclusive_sum / total * 100}
|
||||
row["counterChecks"] = source.get("counterChecks")
|
||||
rows.append(row)
|
||||
groups = {}
|
||||
for variant in ("control", "profiled"):
|
||||
selected = [r for r in rows if r["variant"] == variant]
|
||||
require(len(selected) == 4 and {r["run"] for r in selected} == {"warmup-1", "run-1", "run-2", "run-3"},
|
||||
f"Incomplete compute profile {variant}")
|
||||
require(all(r["warmup"] is (r["run"] == "warmup-1") for r in selected), "Compute profile warmup labels changed")
|
||||
groups[variant] = {"formal": metrics_summary([r for r in selected if not r["warmup"]]),
|
||||
"warmup": metrics_summary([r for r in selected if r["warmup"]])}
|
||||
paired = []
|
||||
for label in ("run-1", "run-2", "run-3"):
|
||||
pair = {r["variant"]: r for r in rows if r["run"] == label}
|
||||
before = pair["control"]["metrics"]["computeProfile.solveSeconds"]
|
||||
after = pair["profiled"]["metrics"]["computeProfile.solveSeconds"]
|
||||
paired.append({"run": label, "controlSeconds": before, "profiledSeconds": after,
|
||||
"incrementPercent": (after / before - 1) * 100})
|
||||
return {"available": True, "source": relative, "groups": groups, "runs": rows,
|
||||
"runtimeArguments": runtime, "allFullParityRecorded": True,
|
||||
"pairedSolveIncrements": paired,
|
||||
"pairedSolveIncrementPercent": statistics([r["incrementPercent"] for r in paired]),
|
||||
"groupMedianRatioOverheadFraction": data.get("instrumentationOverheadFraction"),
|
||||
"interpretation": data.get("interpretation"),
|
||||
"scopeStatistics": "Exclusive scopes partition EACH run's integration wall time. Percentages are computed within each run before n/min/median/max; summed medians are not an exact total. Inclusive Jacobian contains its nested canonical base/probe RHS and overlaps total RHS.",
|
||||
"nonlinearFailures": "The current auto CVODE nonlinear-convergence-failure counters cover all restart segments. The previous report's 414 described dense CVODE failures in a different experiment. Neither counts pipe-local Newton exhaustion, rejected steps, or completed-run failures; do not use them as a timing share.",
|
||||
"historicalCounterSource": "repo:docs/other/八路网页求解全流程成本评估-2026-09-11.md:141"}
|
||||
|
||||
def summarize(self) -> dict:
|
||||
groups = {name: self.browser_group(name, *config) for name, config in GROUPS.items()}
|
||||
native = self.native_benchmark()
|
||||
compute = self.compute_profile(native)
|
||||
for key in ("inputSha256", "buildAssetSetSha256", "scriptSha256"):
|
||||
values = {g[key] for g in groups.values()}
|
||||
require(len(values) == 1 and isinstance(next(iter(values)), str) and len(next(iter(values))) == 64,
|
||||
f"Browser group identity mismatch/missing: {key}")
|
||||
require(native["inputSha256"] == groups["baseline"]["inputSha256"], "Native/browser input hash mismatch")
|
||||
dims = {(r["sampleCount"], r["variableCount"]) for g in groups.values() for r in g["runs"]}
|
||||
require(len(dims) == 1, "Browser sample/variable dimensions changed")
|
||||
xmls = {r["backend"]["xmlSha256"] for g in groups.values() for r in g["runs"] if r["backend"]}
|
||||
require(len(xmls) == 1 and None not in xmls, "Profiled browser XML input changed")
|
||||
controls = {goal: {"metric": f"frontend.{metric}", "unit": "ms", **ratio(groups["baseline"]["formal"][f"frontend.{metric}"], groups["optimized"]["formal"][f"frontend.{metric}"])}
|
||||
for goal, metric in GOALS.items()}
|
||||
diagnostics = {}
|
||||
for name in ("baseline", "optimized"):
|
||||
diagnostics[name] = {}
|
||||
for goal, metric in GOALS.items():
|
||||
control = groups[name]["formal"][f"frontend.{metric}"]
|
||||
profiled = groups[name + "-profiled"]["formal"][f"frontend.{metric}"]
|
||||
diagnostics[name][goal] = {"control": control, "profiled": profiled,
|
||||
"observedIncrementPercent": (profiled["median"] / control["median"] - 1) * 100,
|
||||
"statistic": "ratio_of_separately_collected_group_medians", "causalOverheadEstimate": False}
|
||||
stage_comparisons = {}
|
||||
for metric in ("native.solveSeconds", "cWall.projectionSeconds", "cWall.jsonWriteSeconds", "backendSpan.native_indexed_result_read", "backend.httpTotalMs"):
|
||||
stage_comparisons[metric] = {"diagnosticOnly": True, **ratio(groups["baseline-profiled"]["formal"][metric], groups["optimized-profiled"]["formal"][metric])}
|
||||
return {"schemaVersion": 1, "complete": True, "errors": [], "definitions": DEFINITIONS,
|
||||
"validation": {"browserInputAndAssetsAndHarnessHashesEqual": True, "nativeBrowserInputHashEqual": True,
|
||||
"profiledBrowserXmlHashesEqual": True, "nativeXmlHashEqualToBrowserXml": native["xmlSha256"] in xmls,
|
||||
"xmlIdentityNote": "Browser and standalone native XML serialization hashes are recorded separately; equality of the imported JSON is verified, semantic equivalence is not established by an XML hash mismatch alone.",
|
||||
"browserDimensionsEqual": True, "sampleCount": next(iter(dims))[0], "variableCount": next(iter(dims))[1],
|
||||
"rtol": 1e-8, "largePayloadParityCheckedHere": False, "crossModeCountersRequiredEqual": False},
|
||||
"browserGroups": groups, "nativeBenchmark": native, "nativeComputeProfile": compute, "controlComparisons": controls,
|
||||
"profiledStageComparisons": stage_comparisons, "instrumentationDiagnostics": diagnostics,
|
||||
"metricDefinitions": self.metric_definitions, "sources": self.sources}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--root", type=Path, default=REPO / "test/jacobian-20260911")
|
||||
parser.add_argument("--output", type=Path, help="Default: ROOT/cost-summary.json")
|
||||
args = parser.parse_args()
|
||||
summarizer = Summary(args.root)
|
||||
output = args.output or args.root / "cost-summary.json"
|
||||
try:
|
||||
result = summarizer.summarize()
|
||||
except (OSError, ValueError, KeyError, TypeError, IndexError) as exc:
|
||||
result = {"schemaVersion": 1, "complete": False, "errors": [str(exc)],
|
||||
"definitions": DEFINITIONS, "sources": summarizer.sources}
|
||||
result["scriptSha256"] = hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(result, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
||||
print(json.dumps({"complete": result["complete"], "output": str(output), "errors": result["errors"]}, ensure_ascii=False))
|
||||
return 0 if result["complete"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -88,10 +88,19 @@ class NativeExecutionTests(unittest.TestCase):
|
||||
data = execute_native(self.build, config, .02, run_dir=self.root / method)
|
||||
self.assertTrue(data["success"])
|
||||
self.assertEqual(data["simulatedUntil"], .1)
|
||||
self.assertEqual(data["jacobianMode"], "dense-difference" if method == "BDF" else "not-used")
|
||||
self.assertEqual(data["jacobianRhsCalls"], 0) # Compact model has no grouping benefit.
|
||||
self.assertLessEqual(data["maxAcceptedStep"], config.max_step+1e-14)
|
||||
self.assertEqual(set(data["series"]), {"time", *(v.key for v in self.program.variables)})
|
||||
self.assertTrue(all(np.isfinite(v).all() for v in map(np.asarray, data["series"].values())))
|
||||
|
||||
def test_retired_jacobian_selector_is_rejected(self):
|
||||
for policy in ("dense", "auto", "verify"):
|
||||
with self.subTest(policy=policy):
|
||||
result = subprocess.run([str(self.build.executable), "--jacobian", policy],
|
||||
cwd=self.root, capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(result.returncode, 64)
|
||||
|
||||
def test_cancellation_returns_partial_accepted_state(self):
|
||||
config = replace(simulation_config(self.document.simulation), max_step=1e-6)
|
||||
tracker = SolverActivityTracker()
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Small standalone checks of the production colored CVODE Jacobian callback.
|
||||
|
||||
The independent oracle is the actual SUNDIALS 7.4 cvLsDenseDQJac symbol in the
|
||||
installed static library for the unchanged cache path. Canonical-mode tests
|
||||
separately verify a recomputed baseline and the original-fy perturbation policy;
|
||||
that derivative is intentionally not equated to the old shared-cache DQ.
|
||||
Its private headers are used only by this test TU,
|
||||
never by production code. No full application/model compilation is required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from app.simulation.native_codegen.build import LIBRARIES, toolchain
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SOURCE = ROOT / "test/solver-newton-20260911/toolchain/sundials-7.4.0"
|
||||
MODEL_HEADER = r'''
|
||||
#ifndef TEST_MODEL_H
|
||||
#define TEST_MODEL_H
|
||||
#define NSTATES 4
|
||||
#define NOUTPUTS 4
|
||||
#define MODEL_JACOBIAN_COLORED 1
|
||||
#define MODEL_JACOBIAN_COLOR_COUNT 2
|
||||
#define MODEL_JACOBIAN_NNZ 8
|
||||
extern int model_jacobian_col_ptr[5], model_jacobian_row_index[8], model_jacobian_column_color[4];
|
||||
extern const double model_atol[4];
|
||||
double model_next_break(double time,double end);
|
||||
#endif
|
||||
'''
|
||||
HARNESS = r'''
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <math.h>
|
||||
#include "cvode_impl.h"
|
||||
#include "cvode_ls_impl.h"
|
||||
#include "@RUNTIME@"
|
||||
#if SUNDIALS_VERSION_MAJOR != 7 || SUNDIALS_VERSION_MINOR != 4 || SUNDIALS_VERSION_PATCH != 0
|
||||
#error This private-ABI test must use SUNDIALS 7.4.0
|
||||
#endif
|
||||
int model_jacobian_col_ptr[5]={0,2,4,6,8};
|
||||
int model_jacobian_row_index[8]={0,1,0,1,2,3,2,3};
|
||||
int model_jacobian_column_color[4]={0,1,0,1};
|
||||
const double model_atol[4]={1e-9,1e-9,1e-9,1e-9};
|
||||
static int assertions, zero_model, extra_dependency, reject_mode, poll_calls, cancel_poll;
|
||||
static int break_enabled, event_enabled, event_done;
|
||||
static double baseline_state[NSTATES];
|
||||
static int canonical_trace, canonical_count, reject_canonical_base;
|
||||
static double canonical_inputs[16][NSTATES];
|
||||
#define CHECK(condition) do { assertions++; if(!(condition)){fprintf(stderr,"check failed at line %d: %s\n",__LINE__,#condition);exit(1);} } while(0)
|
||||
static int evaluate(const double *y,double *f) {
|
||||
int perturbed=0;
|
||||
for(int i=0;i<NSTATES;i++)perturbed+=y[i]!=baseline_state[i];
|
||||
if((reject_mode==1 && perturbed>1) || (reject_mode==2 && perturbed>0))return 0;
|
||||
if(zero_model){for(int i=0;i<NSTATES;i++)f[i]=0;return 1;}
|
||||
for(int i=0;i<NSTATES;i+=2) {
|
||||
f[i]=y[i]*y[i]+0.5*y[i+1];
|
||||
f[i+1]=sin(y[i+1])+0.25*y[i];
|
||||
}
|
||||
if(extra_dependency)f[0]+=y[2];
|
||||
return 1;
|
||||
}
|
||||
int native_poll(NativeRun *r,double time) {
|
||||
(void)time; poll_calls++;
|
||||
if(cancel_poll && poll_calls>=cancel_poll){r->status=1;return 0;}
|
||||
return 1;
|
||||
}
|
||||
int native_rhs(NativeRun *r,double time,const double *state,double *derivative) {
|
||||
(void)time;r->nfev++;return evaluate(state,derivative);
|
||||
}
|
||||
int native_jacobian_rhs(NativeRun *r,double time,const double *state,double *derivative) {
|
||||
(void)time;r->nfev++;
|
||||
if(canonical_trace) {
|
||||
CHECK(canonical_count<16);
|
||||
memcpy(canonical_inputs[canonical_count++],state,NSTATES*sizeof(double));
|
||||
}
|
||||
if(reject_canonical_base && !memcmp(state,baseline_state,sizeof(baseline_state)))return 0;
|
||||
return evaluate(state,derivative);
|
||||
}
|
||||
int native_append(NativeRun *r,double time,const double *state) {
|
||||
r->final_time=time;memcpy(r->final_state,state,NSTATES*sizeof(double));return 1;
|
||||
}
|
||||
int native_accept(NativeRun *r,double time,double next,const double *old,const double *trial,
|
||||
NativeDense dense,void *context,double *accepted_time,double *accepted_state) {
|
||||
(void)time;(void)old;(void)dense;(void)context;
|
||||
*accepted_time=next;memcpy(accepted_state,trial,NSTATES*sizeof(double));
|
||||
int impact=event_enabled && !event_done && next>=0.0005;
|
||||
if(impact){event_done=1;accepted_state[0]=-accepted_state[0];r->events++;}
|
||||
r->final_time=next;memcpy(r->final_state,accepted_state,NSTATES*sizeof(double));return impact;
|
||||
}
|
||||
double model_next_break(double time,double end) {return break_enabled && time<0.001 && end>0.001?0.001:end;}
|
||||
typedef struct {
|
||||
NativeRun run;
|
||||
CvContext context;
|
||||
SUNContext sun;
|
||||
SUNLinearSolver linear;
|
||||
N_Vector y,fy,trial,ftrial,tmp3,oracle_state;
|
||||
SUNMatrix matrix,oracle;
|
||||
} Fixture;
|
||||
static void fixture_create(Fixture *f) {
|
||||
memset(f,0,sizeof(*f));CHECK(!SUNContext_Create(SUN_COMM_NULL,&f->sun));
|
||||
f->y=N_VNew_Serial(NSTATES,f->sun);f->fy=N_VClone(f->y);f->trial=N_VClone(f->y);
|
||||
f->ftrial=N_VClone(f->y);f->tmp3=N_VClone(f->y);f->oracle_state=N_VClone(f->y);
|
||||
f->matrix=SUNDenseMatrix(NSTATES,NSTATES,f->sun);f->oracle=SUNDenseMatrix(NSTATES,NSTATES,f->sun);
|
||||
f->linear=SUNLinSol_Dense(f->y,f->matrix,f->sun);
|
||||
f->context=(CvContext){.run=&f->run,.solver=CVodeCreate(CV_BDF,f->sun),.weights=N_VClone(f->y),.colored=1};
|
||||
CHECK(f->y && f->fy && f->trial && f->ftrial && f->tmp3 && f->oracle_state && f->matrix && f->oracle && f->linear && f->context.solver && f->context.weights);
|
||||
N_VConst(1,f->y);
|
||||
CHECK(!CVodeInit(f->context.solver,cv_rhs,0,f->y));
|
||||
CHECK(!CVodeSetUserData(f->context.solver,&f->context));
|
||||
CHECK(!CVodeSStolerances(f->context.solver,1e-8,1e-9));
|
||||
CHECK(!CVodeSetLinearSolver(f->context.solver,f->linear,f->matrix));
|
||||
f->run.jacobian_colored=1;
|
||||
}
|
||||
static void fixture_set(Fixture *f,const double *state,const double *weights,double step) {
|
||||
memcpy(N_VGetArrayPointer(f->y),state,NSTATES*sizeof(double));
|
||||
memcpy(baseline_state,state,sizeof(baseline_state));CHECK(evaluate(state,N_VGetArrayPointer(f->fy)));
|
||||
/* Set the actual library's trial-step state, not a second formula oracle. */
|
||||
CVodeMem memory=(CVodeMem)f->context.solver;
|
||||
memory->cv_h=step;memory->cv_next_h=step;
|
||||
memcpy(N_VGetArrayPointer(memory->cv_ewt),weights,NSTATES*sizeof(double));
|
||||
}
|
||||
static void fixture_free(Fixture *f) {
|
||||
CVodeFree(&f->context.solver);SUNLinSolFree(f->linear);
|
||||
if(f->context.reference)SUNMatDestroy(f->context.reference);
|
||||
SUNMatDestroy(f->matrix);SUNMatDestroy(f->oracle);N_VDestroy(f->context.weights);
|
||||
N_VDestroy(f->y);N_VDestroy(f->fy);N_VDestroy(f->trial);N_VDestroy(f->ftrial);N_VDestroy(f->tmp3);N_VDestroy(f->oracle_state);
|
||||
SUNContext_Free(&f->sun);
|
||||
}
|
||||
static int callback(Fixture *f) {
|
||||
return cv_jacobian(0,f->y,f->fy,f->matrix,&f->context,f->ftrial,f->trial,f->tmp3);
|
||||
}
|
||||
static void actual_upstream_oracle(Fixture *f) {
|
||||
memcpy(N_VGetArrayPointer(f->oracle_state),N_VGetArrayPointer(f->y),NSTATES*sizeof(double));
|
||||
CHECK(!cvLsDenseDQJac(0,f->oracle_state,f->fy,f->oracle,(CVodeMem)f->context.solver,f->ftrial));
|
||||
CHECK(!memcmp(N_VGetArrayPointer(f->oracle_state),N_VGetArrayPointer(f->y),NSTATES*sizeof(double)));
|
||||
}
|
||||
static void matrices_equal(Fixture *f) {
|
||||
for(int j=0;j<NSTATES;j++)for(int i=0;i<NSTATES;i++) {
|
||||
double a=SM_ELEMENT_D(f->matrix,i,j),b=SM_ELEMENT_D(f->oracle,i,j);
|
||||
if(memcmp(&a,&b,sizeof(double)))fprintf(stderr,"matrix mismatch (%d,%d): %.17g vs %.17g\n",i,j,a,b);
|
||||
CHECK(!memcmp(&a,&b,sizeof(double)));
|
||||
}
|
||||
}
|
||||
static void formula_oracle(void) {
|
||||
Fixture f;fixture_create(&f);
|
||||
const double states[][4]={{0,1e-8,-2,1e4},{.1,-.3,1.25,-4.25},{-1e-20,0,2e-10,3}};
|
||||
const double weights[][4]={{1e12,1e8,1e-4,1e-10},{2,.5,4,1},{1e-3,1e10,1e4,1}};
|
||||
const double steps[]={1e-12,1e-3,-3e-4,2e5};
|
||||
for(int kind=0;kind<2;kind++)for(int state=0;state<3;state++)for(int step=0;step<4;step++) {
|
||||
zero_model=kind;fixture_set(&f,states[state],weights[state],steps[step]);
|
||||
double saved_y[NSTATES],saved_f[NSTATES];
|
||||
memcpy(saved_y,N_VGetArrayPointer(f.y),sizeof(saved_y));memcpy(saved_f,N_VGetArrayPointer(f.fy),sizeof(saved_f));
|
||||
unsigned long before=f.run.jacobian_rhs;
|
||||
CHECK(!callback(&f));CHECK(f.run.jacobian_rhs-before==MODEL_JACOBIAN_COLOR_COUNT);
|
||||
CHECK(!memcmp(saved_y,N_VGetArrayPointer(f.y),sizeof(saved_y)));CHECK(!memcmp(saved_f,N_VGetArrayPointer(f.fy),sizeof(saved_f)));
|
||||
actual_upstream_oracle(&f);matrices_equal(&f);
|
||||
}
|
||||
zero_model=0;
|
||||
const double round_state[]={.1,.1,.1,.1},round_weights[]={1e6,1e6,1e6,1e6};double inc[NSTATES];
|
||||
fixture_set(&f,round_state,round_weights,1e-12);CHECK(!jac_increments(&f.context,f.y,f.fy,inc));
|
||||
CHECK((round_state[0]+inc[0])-round_state[0]!=inc[0]);
|
||||
CHECK(!callback(&f));actual_upstream_oracle(&f);matrices_equal(&f);
|
||||
fixture_free(&f);
|
||||
}
|
||||
static void coloring_validation(void) {
|
||||
CHECK(valid_coloring());
|
||||
model_jacobian_col_ptr[0]=1;CHECK(!valid_coloring());model_jacobian_col_ptr[0]=0;
|
||||
model_jacobian_col_ptr[2]=9;CHECK(!valid_coloring());model_jacobian_col_ptr[2]=4;
|
||||
model_jacobian_row_index[1]=0;CHECK(!valid_coloring());model_jacobian_row_index[1]=1;
|
||||
model_jacobian_row_index[1]=NSTATES;CHECK(!valid_coloring());model_jacobian_row_index[1]=1;
|
||||
model_jacobian_row_index[0]=-1;CHECK(!valid_coloring());model_jacobian_row_index[0]=0;
|
||||
model_jacobian_column_color[1]=0;CHECK(!valid_coloring());model_jacobian_column_color[1]=1;
|
||||
model_jacobian_column_color[1]=2;CHECK(!valid_coloring());model_jacobian_column_color[1]=1;
|
||||
CHECK(valid_coloring());
|
||||
}
|
||||
static void recoverable_failure(int mode) {
|
||||
Fixture f;fixture_create(&f);const double state[]={1.2,2.1,3.3,4.4},weight[]={1,1,1,1};
|
||||
fixture_set(&f,state,weight,.001);double saved_f[NSTATES];memcpy(saved_f,N_VGetArrayPointer(f.fy),sizeof(saved_f));
|
||||
reject_mode=mode;int code=callback(&f);CHECK(code==(mode==1?0:1));
|
||||
CHECK(f.run.jacobian_fallbacks==1);CHECK(f.run.jacobian_colored_evals==0);
|
||||
CHECK(f.run.jacobian_rhs==(unsigned long)(mode==1?1+NSTATES:2));CHECK(f.run.nfev==f.run.jacobian_rhs);
|
||||
CHECK(!memcmp(state,N_VGetArrayPointer(f.y),sizeof(state)));CHECK(!memcmp(saved_f,N_VGetArrayPointer(f.fy),sizeof(saved_f)));
|
||||
CHECK(!memcmp(state,N_VGetArrayPointer(f.trial),sizeof(state)));
|
||||
reject_mode=0;if(mode==1){actual_upstream_oracle(&f);matrices_equal(&f);}fixture_free(&f);
|
||||
}
|
||||
static void cancellation(void) {
|
||||
for(int after=1;after<=2;after++) {
|
||||
Fixture f;fixture_create(&f);const double state[]={1.2,2.1,3.3,4.4},weight[]={1,1,1,1};
|
||||
fixture_set(&f,state,weight,.001);double saved_f[NSTATES];memcpy(saved_f,N_VGetArrayPointer(f.fy),sizeof(saved_f));
|
||||
cancel_poll=after;poll_calls=0;CHECK(callback(&f)<0);
|
||||
CHECK(f.run.jacobian_fallbacks==0);CHECK(f.run.jacobian_rhs==(unsigned long)(after-1));CHECK(f.run.nfev==f.run.jacobian_rhs);
|
||||
CHECK(f.run.status==1);CHECK(!memcmp(state,N_VGetArrayPointer(f.y),sizeof(state)));CHECK(!memcmp(saved_f,N_VGetArrayPointer(f.fy),sizeof(saved_f)));
|
||||
cancel_poll=0;fixture_free(&f);
|
||||
}
|
||||
}
|
||||
static void verify_missing_dependency(void) {
|
||||
Fixture f;fixture_create(&f);const double state[]={1.2,2.1,3.3,4.4},weight[]={1,1,1,1};
|
||||
extra_dependency=1;fixture_set(&f,state,weight,.001);
|
||||
f.context.reference=SUNDenseMatrix(NSTATES,NSTATES,f.sun);CHECK(f.context.reference!=NULL);CHECK(valid_coloring());
|
||||
CHECK(!callback(&f));CHECK(f.run.jacobian_rhs==MODEL_JACOBIAN_COLOR_COUNT+NSTATES);
|
||||
CHECK(f.run.jacobian_checks==1);CHECK(f.run.jacobian_mismatches==1);CHECK(f.run.jacobian_fallbacks==1);
|
||||
CHECK(!f.context.colored && !f.run.jacobian_colored);actual_upstream_oracle(&f);matrices_equal(&f);
|
||||
CHECK(!CVodeReInit(f.context.solver,.1,f.y));
|
||||
const double fresh_weights[]={7,11,13,17};fixture_set(&f,state,fresh_weights,1e-5);
|
||||
unsigned long before=f.run.jacobian_rhs;
|
||||
CHECK(!callback(&f));CHECK(f.run.jacobian_rhs-before==NSTATES);
|
||||
CHECK(f.run.jacobian_checks==1 && f.run.jacobian_mismatches==1 && f.run.jacobian_fallbacks==1);
|
||||
CHECK(!f.context.colored);actual_upstream_oracle(&f);matrices_equal(&f);
|
||||
fixture_free(&f);
|
||||
}
|
||||
static NativeRun integration_run(int verify) {
|
||||
NativeRun run={0};run.jacobian_verify=verify;
|
||||
run.options=(NativeOptions){0,.002,.0001,.0002,1e-8,10,1,0,NULL};
|
||||
for(int i=0;i<NSTATES;i++)run.final_state[i]=.1*(i+1);
|
||||
memcpy(baseline_state,run.final_state,sizeof(baseline_state));event_done=0;
|
||||
CHECK(native_bdf(&run));CHECK(run.starts==3);CHECK(run.events==1);
|
||||
CHECK(run.nfev==run.cvode_rhs+run.linear_rhs+run.jacobian_rhs);
|
||||
CHECK(run.njev>0 && run.nlu>0 && run.accepted>0);
|
||||
return run;
|
||||
}
|
||||
static void integration_restart_counters(void) {
|
||||
break_enabled=1;event_enabled=1;
|
||||
/* No user mode selects default DQ. Invalid structural metadata must still
|
||||
fall back safely, including when diagnostic verification is requested. */
|
||||
model_jacobian_column_color[1]=0;CHECK(!valid_coloring());
|
||||
NativeRun fallback=integration_run(0),fallback_verify=integration_run(1);
|
||||
model_jacobian_column_color[1]=1;CHECK(valid_coloring());
|
||||
NativeRun colored=integration_run(0),verify=integration_run(1);
|
||||
CHECK(!fallback.jacobian_colored && !fallback_verify.jacobian_colored);
|
||||
CHECK(fallback.jacobian_rhs==0 && fallback.linear_rhs==fallback.njev*NSTATES);
|
||||
CHECK(fallback_verify.jacobian_rhs==0 && fallback_verify.linear_rhs==fallback_verify.njev*NSTATES);
|
||||
CHECK(fallback_verify.jacobian_checks==0);
|
||||
CHECK(colored.jacobian_colored && verify.jacobian_colored);
|
||||
CHECK(colored.linear_rhs==0 && colored.jacobian_rhs==colored.njev*MODEL_JACOBIAN_COLOR_COUNT);
|
||||
CHECK(colored.jacobian_checks==0);
|
||||
CHECK(verify.linear_rhs==0 && verify.jacobian_rhs==verify.njev*(MODEL_JACOBIAN_COLOR_COUNT+NSTATES));
|
||||
CHECK(verify.jacobian_checks==verify.njev && verify.jacobian_mismatches==0);
|
||||
CHECK(fallback.accepted==colored.accepted && fallback.rejected==colored.rejected && fallback.njev==colored.njev && fallback.nlu==colored.nlu);
|
||||
CHECK(fallback.accepted==verify.accepted && fallback.rejected==verify.rejected && fallback.njev==verify.njev && fallback.nlu==verify.nlu);
|
||||
CHECK(!memcmp(fallback.final_state,colored.final_state,sizeof(fallback.final_state)));
|
||||
CHECK(!memcmp(fallback.final_state,verify.final_state,sizeof(fallback.final_state)));
|
||||
CHECK(!memcmp(fallback.final_state,fallback_verify.final_state,sizeof(fallback.final_state)));
|
||||
}
|
||||
|
||||
#if defined(MODEL_JACOBIAN_CANONICAL_RHS) && MODEL_JACOBIAN_CANONICAL_RHS
|
||||
static void canonical_expected(Fixture *f,const double *increments) {
|
||||
double state[NSTATES],base[NSTATES],probe[NSTATES];
|
||||
memcpy(state,N_VGetArrayPointer(f->y),sizeof(state));CHECK(evaluate(state,base));
|
||||
for(int j=0;j<NSTATES;j++) {
|
||||
double saved=state[j];state[j]+=increments[j];CHECK(evaluate(state,probe));state[j]=saved;
|
||||
for(int i=0;i<NSTATES;i++)SM_ELEMENT_D(f->oracle,i,j)=(1.0/increments[j])*(probe[i]-base[i]);
|
||||
}
|
||||
}
|
||||
static void canonical_baseline(void) {
|
||||
Fixture f;fixture_create(&f);
|
||||
const double state[]={.1,.2,.3,.4},weight[]={1,2,3,4};
|
||||
const double legacy_offset[]={5e5,-7e4,9e3,-1e2};
|
||||
fixture_set(&f,state,weight,1e5);
|
||||
double original_fy[NSTATES],canonical_fy[NSTATES],increments[NSTATES],wrong_increments[NSTATES];
|
||||
memcpy(canonical_fy,N_VGetArrayPointer(f.fy),sizeof(canonical_fy));
|
||||
for(int i=0;i<NSTATES;i++)N_VGetArrayPointer(f.fy)[i]+=legacy_offset[i];
|
||||
memcpy(original_fy,N_VGetArrayPointer(f.fy),sizeof(original_fy));
|
||||
CHECK(!jac_increments(&f.context,f.y,f.fy,increments));
|
||||
memcpy(N_VGetArrayPointer(f.tmp3),canonical_fy,sizeof(canonical_fy));
|
||||
CHECK(!jac_increments(&f.context,f.y,f.tmp3,wrong_increments));
|
||||
CHECK(memcmp(increments,wrong_increments,sizeof(increments))!=0);
|
||||
canonical_expected(&f,increments);
|
||||
canonical_trace=1;canonical_count=0;
|
||||
CHECK(!callback(&f));
|
||||
CHECK(canonical_count==1+MODEL_JACOBIAN_COLOR_COUNT);
|
||||
CHECK(f.run.jacobian_rhs==(unsigned long)canonical_count && f.run.nfev==f.run.jacobian_rhs);
|
||||
CHECK(!memcmp(canonical_inputs[0],state,sizeof(state)));
|
||||
for(int color=0;color<MODEL_JACOBIAN_COLOR_COUNT;color++)for(int j=0;j<NSTATES;j++) {
|
||||
double expected=state[j]+(model_jacobian_column_color[j]==color?increments[j]:0);
|
||||
CHECK(canonical_inputs[1+color][j]==expected);
|
||||
}
|
||||
CHECK(!memcmp(state,N_VGetArrayPointer(f.y),sizeof(state)));
|
||||
CHECK(!memcmp(original_fy,N_VGetArrayPointer(f.fy),sizeof(original_fy)));
|
||||
matrices_equal(&f);
|
||||
/* Verify and disabled-colored paths must share the canonical baseline too. */
|
||||
f.context.reference=SUNDenseMatrix(NSTATES,NSTATES,f.sun);CHECK(f.context.reference!=NULL);
|
||||
canonical_count=0;unsigned long before=f.run.jacobian_rhs;
|
||||
CHECK(!callback(&f));CHECK(canonical_count==1+MODEL_JACOBIAN_COLOR_COUNT+NSTATES);
|
||||
CHECK(f.run.jacobian_rhs-before==(unsigned long)canonical_count);
|
||||
CHECK(f.run.jacobian_checks==1 && f.run.jacobian_mismatches==0);matrices_equal(&f);
|
||||
f.context.colored=0;f.run.jacobian_colored=0;canonical_count=0;before=f.run.jacobian_rhs;
|
||||
CHECK(!callback(&f));CHECK(canonical_count==1+NSTATES);
|
||||
CHECK(f.run.jacobian_rhs-before==(unsigned long)canonical_count);matrices_equal(&f);
|
||||
/* A rejected grouped probe retries columns without recomputing/mixing base. */
|
||||
f.context.colored=1;f.run.jacobian_colored=1;reject_mode=1;canonical_count=0;before=f.run.jacobian_rhs;
|
||||
CHECK(!callback(&f));CHECK(canonical_count==2+NSTATES);
|
||||
CHECK(f.run.jacobian_rhs-before==(unsigned long)canonical_count);CHECK(f.run.jacobian_fallbacks==1);
|
||||
reject_mode=0;matrices_equal(&f);
|
||||
/* Base failure and cancellation return before any perturbation call. */
|
||||
reject_canonical_base=1;canonical_count=0;before=f.run.jacobian_rhs;
|
||||
CHECK(callback(&f)>0);CHECK(canonical_count==1 && f.run.jacobian_rhs-before==1);
|
||||
CHECK(f.run.jacobian_fallbacks==1);reject_canonical_base=0;
|
||||
canonical_count=0;before=f.run.jacobian_rhs;poll_calls=0;cancel_poll=1;
|
||||
CHECK(callback(&f)<0);CHECK(canonical_count==0 && f.run.jacobian_rhs==before);
|
||||
CHECK(f.run.jacobian_fallbacks==1);cancel_poll=0;
|
||||
CHECK(f.run.nfev==f.run.jacobian_rhs);
|
||||
CHECK(!memcmp(original_fy,N_VGetArrayPointer(f.fy),sizeof(original_fy)));
|
||||
CHECK(!memcmp(state,N_VGetArrayPointer(f.y),sizeof(state)));
|
||||
canonical_trace=0;fixture_free(&f);
|
||||
}
|
||||
#endif
|
||||
int main(int argc,char **argv) {
|
||||
if(argc!=2)return 64;
|
||||
if(!strcmp(argv[1],"formula"))formula_oracle();
|
||||
else if(!strcmp(argv[1],"structure"))coloring_validation();
|
||||
else if(!strcmp(argv[1],"joint-failure"))recoverable_failure(1);
|
||||
else if(!strcmp(argv[1],"individual-failure"))recoverable_failure(2);
|
||||
else if(!strcmp(argv[1],"cancel"))cancellation();
|
||||
else if(!strcmp(argv[1],"verify"))verify_missing_dependency();
|
||||
else if(!strcmp(argv[1],"restart"))integration_restart_counters();
|
||||
#if defined(MODEL_JACOBIAN_CANONICAL_RHS) && MODEL_JACOBIAN_CANONICAL_RHS
|
||||
else if(!strcmp(argv[1],"canonical"))canonical_baseline();
|
||||
#endif
|
||||
else return 64;
|
||||
printf("{\"case\":\"%s\",\"assertions\":%d,\"passed\":true}\n",argv[1],assertions);return 0;
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class NativeJacobianRuntimeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
if not sys.platform.startswith("linux"):
|
||||
raise unittest.SkipTest("The independent private-ABI oracle currently uses Linux SUNDIALS 7.4 static libraries")
|
||||
if not (SOURCE / "src/cvode/cvode_impl.h").is_file():
|
||||
raise unittest.SkipTest("The local SUNDIALS 7.4 source tree is required for this private-ABI oracle")
|
||||
compiler, sundials, _ = toolchain()
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix="native-jacobian-runtime-")
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
directory = Path(cls.directory.name)
|
||||
(directory / "model.h").write_text(MODEL_HEADER)
|
||||
(directory / "harness.c").write_text(HARNESS.replace("@RUNTIME@", str(ROOT / "native/runtime/cvode_solver.c")))
|
||||
cls.executable = directory / "harness"
|
||||
libraries = [sundials / "lib" / f"libsundials_{name}.a" for name in LIBRARIES]
|
||||
command = [compiler, "-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math", "-D_POSIX_C_SOURCE=200809L"]
|
||||
for include in (directory, ROOT / "native/include", sundials / "include", SOURCE / "src/cvode", SOURCE / "src/sundials"):
|
||||
command += ["-I", str(include)]
|
||||
command += [str(directory / "harness.c"), "-Wl,--start-group", *map(str, libraries), "-Wl,--end-group", "-lm", "-o", str(cls.executable)]
|
||||
built = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stdout + built.stderr)
|
||||
cls.canonical_executable = directory / "harness-canonical"
|
||||
canonical_command = command[:-1] + [str(cls.canonical_executable), "-DMODEL_JACOBIAN_CANONICAL_RHS=1"]
|
||||
built = subprocess.run(canonical_command, capture_output=True, text=True, timeout=60)
|
||||
if built.returncode:
|
||||
raise AssertionError(built.stdout + built.stderr)
|
||||
|
||||
def check_case(self, case, *, canonical=False):
|
||||
executable = self.canonical_executable if canonical else self.executable
|
||||
completed = subprocess.run([str(executable), case], capture_output=True, text=True, timeout=10)
|
||||
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
|
||||
self.assertIn('"passed":true', completed.stdout)
|
||||
|
||||
def test_actual_sundials_default_oracle_and_rounding(self):
|
||||
self.check_case("formula")
|
||||
|
||||
def test_canonical_base_recomputed_original_fy_sets_increments_and_all_calls_count(self):
|
||||
self.check_case("canonical", canonical=True)
|
||||
|
||||
def test_coloring_structure_and_bounds(self):
|
||||
self.check_case("structure")
|
||||
|
||||
def test_joint_failure_falls_back_to_complete_dense(self):
|
||||
self.check_case("joint-failure")
|
||||
|
||||
def test_individual_failure_remains_recoverable(self):
|
||||
self.check_case("individual-failure")
|
||||
|
||||
def test_cancel_stops_without_fallback_or_phantom_rhs_count(self):
|
||||
self.check_case("cancel")
|
||||
|
||||
def test_verify_detects_missing_dependency_and_stays_disabled_after_reinit(self):
|
||||
self.check_case("verify")
|
||||
|
||||
def test_event_and_time_boundary_restart_counter_accounting(self):
|
||||
self.check_case("restart")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Compiler structure and fixed-state native probes; no integration is run."""
|
||||
from collections import defaultdict
|
||||
import ctypes
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import struct
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from app.main import compile_system_xml_network
|
||||
from app.simulation.native_codegen.compiler import compile_native_program
|
||||
from app.simulation.native_codegen.extended import compile_extended_program
|
||||
from app.simulation.native_codegen.input import load_input
|
||||
from app.simulation.native_codegen.jacobian import StateDependencies
|
||||
from tests.native_reference import reference_data, reference_network
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def compile_input(path):
|
||||
_, document = load_input(ROOT / path)
|
||||
return compile_native_program(compile_system_xml_network(document))
|
||||
|
||||
|
||||
def array(program, name):
|
||||
match = re.search(r'const int '+name+r'\[\d+\] = \{([^}]*)\};', program.source)
|
||||
if not match:
|
||||
raise AssertionError(f'Missing generated {name}')
|
||||
return list(map(int, match[1].split(',')))
|
||||
|
||||
|
||||
def rows_and_colors(program):
|
||||
pointers = array(program, 'model_jacobian_col_ptr')
|
||||
indices = array(program, 'model_jacobian_row_index')
|
||||
colors = array(program, 'model_jacobian_column_color')
|
||||
rows = [set() for _ in program.state_keys]
|
||||
for column in range(len(colors)):
|
||||
for row in indices[pointers[column]:pointers[column+1]]:
|
||||
rows[row].add(column)
|
||||
return rows, colors
|
||||
|
||||
|
||||
class StructuralDependencyTests(unittest.TestCase):
|
||||
def test_unknown_reachable_values_and_calls_disable_coloring(self):
|
||||
for expression in ('mystery', 'q[900]', 'unreviewed_kernel(y[0])'):
|
||||
deps = StateDependencies(2)
|
||||
deps.expression('dy[0]', expression)
|
||||
deps.expression('dy[1]', '0')
|
||||
result = deps.build()
|
||||
self.assertFalse(result.enabled)
|
||||
self.assertIn('Unresolved', result.reason)
|
||||
deps = StateDependencies(1)
|
||||
deps.expression('w[9]', 'unused_diagnostic[0]')
|
||||
deps.expression('dy[0]', '0')
|
||||
self.assertTrue(deps.build().enabled)
|
||||
self.assertEqual(deps.build().rows, ((0,),))
|
||||
|
||||
def test_scc_closure_keeps_independent_regions_separate(self):
|
||||
deps = StateDependencies(6)
|
||||
for target, inputs in {'h[0]': ('h[1]', 'y[0]'), 'h[1]': ('h[0]', 'y[1]'),
|
||||
'h[2]': ('h[3]', 'y[3]'), 'h[3]': ('h[2]', 'y[4]')}.items():
|
||||
deps.assign(target, inputs)
|
||||
for i in range(6):
|
||||
deps.expression(f'dy[{i}]', '0')
|
||||
deps.assign('dy[2]', ('h[0]',))
|
||||
deps.assign('dy[5]', ('h[2]',))
|
||||
result = deps.build()
|
||||
self.assertTrue(result.enabled)
|
||||
self.assertEqual(result.rows[2], (0, 1, 2))
|
||||
self.assertEqual(result.rows[5], (3, 4, 5))
|
||||
self.assertEqual(result.color_count, 3)
|
||||
|
||||
def test_projection_and_stop_control_dependencies_keep_original_sources(self):
|
||||
deps = StateDependencies(6)
|
||||
deps.project_states([0, 2])
|
||||
deps.project_states([1, 3])
|
||||
for i in range(6):
|
||||
deps.expression(f'dy[{i}]', '0')
|
||||
deps.expression('g[0].p', 'y[0]+y[1]')
|
||||
deps.expression('dy[4]', 'g[0].p')
|
||||
deps.expression('dy[5]', 'y[4]')
|
||||
deps.stop_motion(4, 5)
|
||||
result = deps.build()
|
||||
self.assertEqual(result.rows[4], tuple(range(6)))
|
||||
self.assertEqual(result.rows[5], tuple(range(6)))
|
||||
|
||||
def test_catalog_patterns_have_valid_csc_and_colorings(self):
|
||||
for index, case in enumerate(reference_data()['cases']):
|
||||
with self.subTest(case=index):
|
||||
program = compile_extended_program(reference_network(case))
|
||||
info = program.jacobian_structure
|
||||
self.assertTrue(info['enabled'], info['reason'])
|
||||
self.assertTrue(info['canonicalRhs'])
|
||||
self.assertEqual(info['defaultRuntimePolicy'], info['policy'])
|
||||
self.assertEqual(info['policyScope'], 'default-runtime')
|
||||
self.assertEqual(info['verification'], '--verify-jacobian')
|
||||
self.assertNotIn('activation', info)
|
||||
rows, colors = rows_and_colors(program)
|
||||
for row, entries in enumerate(rows):
|
||||
self.assertIn(row, entries)
|
||||
self.assertEqual(len(entries), len({colors[col] for col in entries}))
|
||||
self.assertEqual(sum(map(len, rows)), info['nonzeros'])
|
||||
self.assertEqual(max(colors)+1, info['colorCount'])
|
||||
eligible = info['colorCount'] < len(program.state_keys)
|
||||
self.assertEqual(info['runtimeEligible'], eligible)
|
||||
self.assertEqual(info['runtimeFallbackReason'] is None, eligible)
|
||||
if eligible:
|
||||
self.assertEqual(info['policy'], 'CVODE colored forward differences; canonical-property-cache RHS')
|
||||
self.assertEqual(info['rhsPolicy'], 'canonical-property-cache finite differences')
|
||||
else:
|
||||
self.assertEqual(info['policy'], 'CVODE default dense differences')
|
||||
self.assertEqual(info['rhsPolicy'], 'ordinary model_eval')
|
||||
|
||||
def test_eight_branch_pattern_covers_mechanical_volume_and_uses_fewer_groups(self):
|
||||
program = compile_input('tests/data/test-mql-8-corrected.json')
|
||||
rows, colors = rows_and_colors(program)
|
||||
self.assertLess(max(colors)+1, len(program.state_keys)//2)
|
||||
self.assertTrue(program.jacobian_structure['runtimeEligible'])
|
||||
self.assertEqual(program.jacobian_structure['defaultRuntimePolicy'],
|
||||
'CVODE colored forward differences; canonical-property-cache RHS')
|
||||
self.assertIn('#define MODEL_JACOBIAN_CANONICAL_RHS 1', program.header)
|
||||
self.assertEqual(len(colors), len(program.state_keys))
|
||||
index = {key: i for i, key in enumerate(program.state_keys)}
|
||||
# PNCH012 pressure/energy includes piston volume from two moving masses;
|
||||
# a gas-only origin label would miss these position dependencies.
|
||||
target = index['amesim_pnch012_11.U']
|
||||
self.assertIn(index['amesim_mecmas21_5.x'], rows[target])
|
||||
self.assertIn(index['amesim_mecmas21_9.x'], rows[target])
|
||||
|
||||
def test_compact_path_retains_dense_fallback(self):
|
||||
program = compile_input('tests/fixtures/native-skill-test.xml')
|
||||
self.assertFalse(program.jacobian_structure['enabled'])
|
||||
self.assertFalse(program.jacobian_structure['runtimeEligible'])
|
||||
self.assertTrue(program.jacobian_structure['runtimeFallbackReason'])
|
||||
self.assertEqual(program.jacobian_structure['defaultRuntimePolicy'], 'CVODE default dense differences')
|
||||
self.assertEqual(program.jacobian_structure['rhsPolicy'], 'ordinary model_eval')
|
||||
self.assertIn('#define MODEL_JACOBIAN_COLORED 0', program.header)
|
||||
self.assertIn('#define MODEL_JACOBIAN_CANONICAL_RHS 0', program.header)
|
||||
self.assertNotIn('model_eval_jacobian', program.header)
|
||||
|
||||
|
||||
class NativeJacobianProbeTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
command = shlex.split(os.environ.get('CC', ''))
|
||||
if not command:
|
||||
compiler = shutil.which('gcc') or shutil.which('clang')
|
||||
if not compiler:
|
||||
raise unittest.SkipTest('A C compiler is required for fixed-state probes')
|
||||
command = [compiler]
|
||||
cls.compiler = command
|
||||
cls.directory = tempfile.TemporaryDirectory(prefix='native-jacobian-probe-')
|
||||
cls.addClassCleanup(cls.directory.cleanup)
|
||||
cls.root = Path(cls.directory.name)
|
||||
cls.fixture = json.loads((ROOT/'tests/fixtures/native-jacobian-cache-state.json').read_text())
|
||||
if hashlib.sha256((ROOT/cls.fixture['input']).read_bytes()).hexdigest() != cls.fixture['inputSha256']:
|
||||
raise AssertionError('Update the fixed-state fixture deliberately when changing the physical input')
|
||||
cls.program = compile_input(cls.fixture['input'])
|
||||
cls.n = len(cls.program.state_keys)
|
||||
cls.noutputs = len(cls.program.variables)
|
||||
cls.library = cls.build_library('current', cls.program.source)
|
||||
# Restore the pre-canonical gas-seeding policy for BOTH entrypoints in
|
||||
# an isolated TU. This checks ordinary dispatch/cache behavior on each
|
||||
# platform without requiring identical libm rounding across platforms.
|
||||
old = 'gas_properties=canonical?NULL:properties;'
|
||||
if cls.program.source.count(old) != 1:
|
||||
raise AssertionError('Update the explicitly restored baseline policy when changing the generator')
|
||||
baseline = cls.program.source.replace(old, 'gas_properties=properties;(void)canonical;')
|
||||
cls.baseline = cls.build_library('seeded-baseline', baseline)
|
||||
|
||||
@classmethod
|
||||
def build_library(cls, name, source):
|
||||
directory = cls.root/name
|
||||
directory.mkdir()
|
||||
(directory/'model.c').write_text(source)
|
||||
(directory/'model.h').write_text(cls.program.header)
|
||||
output = directory/('probe.dll' if os.name == 'nt' else 'probe.so')
|
||||
command = cls.compiler + ['-std=c11', '-O3', '-shared', '-Wall', '-Wextra', '-Werror',
|
||||
'-ffp-contract=off', '-fno-fast-math']
|
||||
if os.name != 'nt':
|
||||
command.append('-fPIC')
|
||||
command += ['-I', str(directory), '-I', str(ROOT/'native/include'),
|
||||
str(directory/'model.c'), str(ROOT/'native/components/kernels.c'), '-lm', '-o', str(output)]
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=60)
|
||||
if result.returncode:
|
||||
raise AssertionError(result.stderr)
|
||||
library = ctypes.CDLL(str(output))
|
||||
if os.name == 'nt':
|
||||
import _ctypes
|
||||
cls.addClassCleanup(_ctypes.FreeLibrary, library._handle)
|
||||
for name in ('model_eval', 'model_eval_jacobian'):
|
||||
function = getattr(library, name)
|
||||
function.argtypes = [ctypes.c_double, *([ctypes.POINTER(ctypes.c_double)]*3)]
|
||||
function.restype = ctypes.c_int
|
||||
return library
|
||||
|
||||
def evaluate(self, values, *, canonical=True, library=None):
|
||||
state = (ctypes.c_double*self.n)(*values)
|
||||
derivative = (ctypes.c_double*self.n)()
|
||||
outputs = (ctypes.c_double*self.noutputs)()
|
||||
function = getattr(library or self.library, 'model_eval_jacobian' if canonical else 'model_eval')
|
||||
self.assertEqual(function(self.fixture['time'], state, derivative, outputs), 1)
|
||||
self.assertEqual(list(state), values, 'RHS evaluation mutated its input state')
|
||||
return list(derivative), list(outputs)
|
||||
|
||||
def test_normal_rhs_preserves_seeded_baseline_bits(self):
|
||||
for column in (None, 71, 80, 81, 84):
|
||||
state = self.fixture['state'][:]
|
||||
if column is not None:
|
||||
state[column] += math.sqrt(2**-52)*abs(state[column])
|
||||
actual = self.evaluate(state, canonical=False)
|
||||
expected = self.evaluate(state, canonical=False, library=self.baseline)
|
||||
for got, want in zip(actual, expected):
|
||||
self.assertEqual(struct.pack(f'={len(got)}d', *got), struct.pack(f'={len(want)}d', *want))
|
||||
|
||||
def test_canonical_full_dense_and_colored_differences_agree(self):
|
||||
rows, colors = rows_and_colors(self.program)
|
||||
state = self.fixture['state'][:]
|
||||
base, _ = self.evaluate(state)
|
||||
increments = [max(math.sqrt(2**-52)*abs(value), 1e-14) for value in state]
|
||||
dense = []
|
||||
for column in range(self.n):
|
||||
trial = state[:]
|
||||
trial[column] += increments[column]
|
||||
value, _ = self.evaluate(trial)
|
||||
differences = [value[row]-base[row] for row in range(self.n)]
|
||||
for row in range(self.n):
|
||||
if column not in rows[row]:
|
||||
self.assertEqual(differences[row], 0, (row, column))
|
||||
dense.append(differences)
|
||||
groups = defaultdict(list)
|
||||
for column, color in enumerate(colors):
|
||||
groups[color].append(column)
|
||||
for columns in groups.values():
|
||||
trial = state[:]
|
||||
for column in columns:
|
||||
trial[column] += increments[column]
|
||||
value, _ = self.evaluate(trial)
|
||||
for column in columns:
|
||||
for row in range(self.n):
|
||||
if column in rows[row]:
|
||||
self.assertEqual(value[row]-base[row], dense[column][row], (row, column))
|
||||
# The historically offending cross-branch entries must be exact zeros.
|
||||
for column in (80, 81):
|
||||
for row in (68, 69, 70, 86):
|
||||
self.assertEqual(dense[column][row], 0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -153,7 +153,8 @@ int main(void) {
|
||||
|
||||
def test_web_stream_completes_mql4_and_matches_amesim_reference(self):
|
||||
reference=json.loads((ROOT/'tests/baselines/simulation/test_mql_4/test-mql-4-amesim-reference.json').read_text())
|
||||
xml=(ROOT/'tests/fixtures/amesim/test-mql-4-corrected.xml').read_bytes()
|
||||
from app.simulation.native_codegen.input import load_input
|
||||
xml,_=load_input(ROOT/'tests/data/test-mql-4-corrected.json')
|
||||
events=[json.loads(line) for line in simulation_event_stream(xml)]
|
||||
self.assertFalse([e for e in events if e['event']=='error'])
|
||||
self.assertTrue(any(e['event']=='progress' for e in events))
|
||||
@@ -164,6 +165,12 @@ int main(void) {
|
||||
self.assertEqual(result['diagnostics']['integration']['rtol'],1e-8)
|
||||
self.assertEqual(result['diagnostics']['integration']['method'],'BDF')
|
||||
self.assertEqual(result['diagnostics']['stateCount'],64)
|
||||
native=result['diagnostics']['native']
|
||||
self.assertEqual(native['jacobianMode'],'colored-difference')
|
||||
self.assertGreater(native['jacobianColoredEvals'],0)
|
||||
self.assertEqual(native['cvodeLinearRhsCalls'],0)
|
||||
self.assertEqual(native['jacobianRhsCalls'],16*native['njev'])
|
||||
self.assertEqual(native['jacobianFallbacks'],0)
|
||||
# Bound the formerly stalled tiny-step failure by work, not machine time.
|
||||
self.assertLess(result['diagnostics']['native']['nfev'],60000)
|
||||
series=result['series']; times=series['time']
|
||||
|
||||
@@ -104,7 +104,7 @@ class NativeResultTransportTests(unittest.TestCase):
|
||||
result = next(event['result'] for event in map(json.loads, body.splitlines()) if event['event']=='result')
|
||||
self.assertEqual(result['status'], status)
|
||||
self.assertTrue(result['partial'])
|
||||
self.assertLess(result['simulatedUntil'], .1)
|
||||
self.assertEqual(result['simulatedUntil'], 0.0)
|
||||
self.assertEqual(result['series']['time'][-1], result['simulatedUntil'])
|
||||
self.assertEqual(AsgiClient(app).get('/api/system-xml/simulations/'+task.simulation_id).json()['result'], result)
|
||||
|
||||
|
||||
Reference in new issue
Block a user