From aa4951b14ea6453138e3e877f1167a28100f486f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8D=A2=E4=BA=AC=E6=B3=BD?= Date: Sat, 12 Sep 2026 03:57:31 +0000 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=9B=85=E5=8F=AF=E6=AF=94?= =?UTF-8?q?=E7=9F=A9=E9=98=B5=E8=AE=A1=E7=AE=97=EF=BC=9B=E7=AB=AF=E5=8F=A3?= =?UTF-8?q?=E8=BD=AC=E5=8F=91=E6=83=85=E5=86=B5=E4=B8=8B=E4=BB=BF=E7=9C=9F?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E4=BC=A0=E8=BE=93=E6=96=B9=E5=BC=8F=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/simulation/native_codegen/compiler.py | 9 +- app/simulation/native_codegen/extended.py | 36 +- app/simulation/native_codegen/jacobian.py | 214 ++ app/simulation/native_codegen/runner.py | 6 +- .../2026-09-11/jacobian-cost-summary.json | 1488 +++++++++++ .../jacobian-production-summary.json | 104 + .../2026-09-11/jacobian-time-comparison.svg | 2185 +++++++++++++++++ .../2026-09-12-result-transfer/README.md | 25 + .../2026-09-12-result-transfer/manifest.json | 52 + .../optimization.patch | 1246 ++++++++++ ...雅可比算法正式启用与网页验收-2026-09-11.md | 61 + ...雅可比结构着色试验与八路验证-2026-09-11.md | 322 +++ docs/update-log/更新日志-2026-09-11.md | 4 + native/README.md | 10 +- native/include/runtime.h | 5 + native/runtime/common.c | 13 + native/runtime/cvode_solver.c | 190 +- native/runtime/main.c | 8 + .../fixtures/native-jacobian-cache-state.json | 141 ++ tests/manual/benchmark_native_jacobian.py | 522 ++++ tests/manual/compare_jacobian_trajectories.py | 301 +++ tests/manual/native_compute_profile.py | 43 +- tests/manual/summarize_jacobian_cost.py | 407 +++ tests/test_native_codegen.py | 9 + tests/test_native_jacobian_runtime.py | 392 +++ tests/test_native_jacobian_structure.py | 257 ++ tests/test_native_pipe_physics.py | 9 +- tests/test_native_result_transport.py | 2 +- 28 files changed, 8038 insertions(+), 23 deletions(-) create mode 100644 app/simulation/native_codegen/jacobian.py create mode 100644 docs/other/assets/2026-09-11/jacobian-cost-summary.json create mode 100644 docs/other/assets/2026-09-11/jacobian-production-summary.json create mode 100644 docs/other/assets/2026-09-11/jacobian-time-comparison.svg create mode 100644 docs/other/backups/2026-09-12-result-transfer/README.md create mode 100644 docs/other/backups/2026-09-12-result-transfer/manifest.json create mode 100644 docs/other/backups/2026-09-12-result-transfer/optimization.patch create mode 100644 docs/other/雅可比算法正式启用与网页验收-2026-09-11.md create mode 100644 docs/other/雅可比结构着色试验与八路验证-2026-09-11.md create mode 100644 tests/fixtures/native-jacobian-cache-state.json create mode 100644 tests/manual/benchmark_native_jacobian.py create mode 100644 tests/manual/compare_jacobian_trajectories.py create mode 100644 tests/manual/summarize_jacobian_cost.py create mode 100644 tests/test_native_jacobian_runtime.py create mode 100644 tests/test_native_jacobian_structure.py diff --git a/app/simulation/native_codegen/compiler.py b/app/simulation/native_codegen/compiler.py index a0878a0..2a5add8 100644 --- a/app/simulation/native_codegen/compiler.py +++ b/app/simulation/native_codegen/compiler.py @@ -13,6 +13,7 @@ from app.simulation.core.metadata import ResultVariableMetadata from app.simulation.systems.network import SimulationNetwork from .contracts import SUPPORTED_TYPES, SUPPORTED_VERSIONS from .tolerances import state_absolute_tolerance +from .jacobian import JacobianStructure class NativeCapabilityError(ValueError): @@ -35,6 +36,7 @@ class NativeProgram: variables: tuple[ResultVariableMetadata, ...] component_types: tuple[str, ...] evaluation_schedule: dict | None = None + jacobian_structure: dict | None = None def manifest(self) -> dict: return { @@ -42,7 +44,8 @@ class NativeProgram: "variables": [v.as_dict() for v in self.variables], "componentTypes": self.component_types, "componentVersions": {name: SUPPORTED_VERSIONS[name] for name in self.component_types}, - "jacobianPolicy": "CVODE default; no custom Jacobian", + "jacobianPolicy": (self.jacobian_structure or {}).get("policy", "CVODE default; no custom Jacobian"), + "jacobianStructure": self.jacobian_structure, "evaluationSchedule": self.evaluation_schedule or {"strategy": "storage-anchored", "cyclicBlockCount": 0}, } @@ -362,6 +365,7 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr next_event.append(f"if (t < {_number(c.time)}) result = fmin(result, {_number(c.time)});") else: next_event.append(f"result = fmin(result, native_signal_break(t, end, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index}));") + jacobian = JacobianStructure.dense(len(state_keys), 'Compact storage-anchored lowering retains default differences') source = '\n'.join([ '#include "model.h"', '#include ', *declarations, f"const NativeStop model_stops[{max(1,len(stops))}] = {{{stop_c}}};", @@ -387,9 +391,10 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr extern const NativeStop model_stops[{max(1,len(stops))}]; extern const double model_atol[NSTATES]; extern const char *const model_output_keys[NOUTPUTS]; +{chr(10).join(jacobian.header_lines())} int model_init(double *y); int model_eval(double t, const double *y, double *dy, double *w); double model_next_break(double t, double end); #endif ''' - return NativeProgram(source, header, tuple(state_keys), variables, tuple(sorted({c.model_type for c in components}))) + return NativeProgram(source, header, tuple(state_keys), variables, tuple(sorted({c.model_type for c in components})),jacobian_structure=jacobian.manifest()) diff --git a/app/simulation/native_codegen/extended.py b/app/simulation/native_codegen/extended.py index dc3a54f..3791a32 100644 --- a/app/simulation/native_codegen/extended.py +++ b/app/simulation/native_codegen/extended.py @@ -13,6 +13,7 @@ from .compiler import NativeCapabilityError, NativeProgram, _Groups, _number as from .contracts import SUPPORTED_VERSIONS from .schedule import Computation, EvaluationSchedule, references from .tolerances import state_absolute_tolerance +from .jacobian import StateDependencies, expression_inputs GAS_TYPES = {'amesim_pnch023', 'amesim_pnch012', 'amesim_pnl0001', @@ -149,6 +150,7 @@ def compile_extended_program(network): nstates = max(len(state_keys), 1) if not initial: initial = [0.0] + dependencies = StateDependencies(len(state_keys)) lines, declarations, breaks = [], [], [] assigned = set() def w(c, field): @@ -156,6 +158,7 @@ def compile_extended_program(network): return f'w[{slots[name+"."+field]}]' def put(c, field, expr, dest=None): (lines if dest is None else dest).append(f'{w(c,field)} = {expr};') + dependencies.expression(w(c,field), expr) assigned.add((c if isinstance(c,str) else c.name)+'.'+field) def y(c, field): return f'y[{states[c.name,field]}]' @@ -268,7 +271,10 @@ def compile_extended_program(network): if kind=='amesim_pnch012' else c.cvol if kind=='amesim_pnch023' else c.V if kind in ('cylinder','tank') else c.volume) gas_initializers.append(f'if(!native_medium_init({medium(c)},{num(p0)},{num(T0)},{num(initial_volume)},{int(kind in ("cylinder","tank"))},&y[{states[c.name,"m"+suffix]}])) return 0;') - lines.append(f'if(!native_medium_gas_context(properties,{medium(c)}, {y(c,"m"+suffix)}, {y(c,"U"+suffix)}, {V}, &{gas})) return 0;') + lines.append(f'if(!native_medium_gas_context(gas_properties,{medium(c)}, {y(c,"m"+suffix)}, {y(c,"U"+suffix)}, {V}, &{gas})) return 0;') + gas_inputs = expression_inputs(f'{y(c,"m"+suffix)}+{y(c,"U"+suffix)}+({V})') + for field in ('p','T','rho','u','h'): + dependencies.assign(gas+'.'+field, gas_inputs) for field in ('m', 'U'): put(c, field+suffix, y(c, field+suffix)) for field in ('p','T','rho','u','h'): @@ -304,8 +310,11 @@ def compile_extended_program(network): project += [f'projected[{i}]=mass*{num(volume/total)};projected[{i+1}]=energy*{num(volume/total)};'] project.append('}') coupled.append((root,offsets,volumes)) + dependencies.project_states(offsets) + dependencies.project_states([i+1 for i in offsets]) for root, gas in anchor.items(): lines.append(f'p[{pgi[root]}]={gas}.p;') + dependencies.assign(f'p[{pgi[root]}]', (gas+'.p',)) h_initial = {f'h[{pi[endpoint]}]': gas+'.h' for endpoint,gas in port_gas.items()} operations, flow_known, flow_eq = [], set(), [] @@ -437,9 +446,12 @@ def compile_extended_program(network): for root,index in pgi.items(): labels[f'p[{index}]']='pressure:'+','.join('.'.join(ep) for ep in pneu if groups.find(ep)==root) schedule=EvaluationSchedule(operations,known,labels) + for operation in operations: + dependencies.computation(operation) for target,expr in h_initial.items(): if target not in schedule.producers: lines.append(f'{target}={expr};') + dependencies.expression(target, expr) schedule_helpers, scheduled_lines=schedule.emit() lines += scheduled_lines for c in components: @@ -481,7 +493,10 @@ def compile_extended_program(network): if c.use_friction: extra+=f'-{num(c.wind)}*{v}*fabs({v})' feq.append(({w(c,'port_1.f'):1,w(c,'port_2.f'):1,w(ref,'a'):-c.mass},f'-({extra})')) unknownf=[w(c,name+'.f') for c in components for name in mnames(c)]+[w(group[0],'a') for group in mass_groups.values()] - lines += linear_schedule(feq,unknownf) + # Keep the same elimination/order while registering its structured bindings. + for target, expression in linear_assignments(feq, unknownf): + lines.append(f'{"double " if target.startswith("b") else ""}{target} = {expression};') + dependencies.expression(target, expression) for c in components: for name in mnames(c): assigned.add(c.name+'.'+name+'.f') if c.model_type=='amesim_lmechn1': @@ -491,6 +506,8 @@ def compile_extended_program(network): ref=group[0];vi=states[ref.name,'v'];xi=states[ref.name,'x'] limits=[c for c in group if int(c.stoptype) in (1,3)] lines += [f'dy[{vi}]={w(ref,"a")};dy[{xi}]={y(ref,"v")};'] + dependencies.expression(f'dy[{vi}]', w(ref,'a')) + dependencies.expression(f'dy[{xi}]', y(ref,'v')) if limits: lower=max(c.xmin for c in limits);upper=min(c.xmax for c in limits) if lower>upper or ref.x0upper+1e-12: @@ -502,6 +519,7 @@ def compile_extended_program(network): thresholds.append(max((c.restdvel for c in active if int(c.stoptype)==3),default=0)) stops.append((vi,lower,upper,*restitution,*thresholds)) lines.append(f'native_stop_motion({y(ref,"x")},{y(ref,"v")},{num(lower)},{num(upper)},&dy[{vi}],&dy[{xi}]);') + dependencies.stop_motion(vi, xi) for c in group: put(c,'a',f'dy[{vi}]') for c in components: @@ -530,6 +548,8 @@ def compile_extended_program(network): temp=f'.5*({gases[c.name,1]}.T+{gases[c.name,2]}.T)' if half else gas+'.T' heat=f'{num(c.kth*c.exchange_area/(2 if half else 1))}*({num(c.extemp)}-({temp}))' lines += [f'dy[{states[c.name,"m"+suffix]}]={mass};',f'dy[{states[c.name,"U"+suffix]}]={energy}+({heat});'] + dependencies.expression(f'dy[{states[c.name,"m"+suffix]}]', mass) + dependencies.expression(f'dy[{states[c.name,"U"+suffix]}]', f'{energy}+({heat})') if kind.startswith('amesim_pnl'): diag=[] if kind=='amesim_pnl0002': @@ -562,21 +582,26 @@ def compile_extended_program(network): lines.append('{ double mass='+ '+'.join(f'dy[{i}]' for i in offsets)+',energy='+ '+'.join(f'dy[{i+1}]' for i in offsets)+';') for i,volume in zip(offsets,volumes): lines.append(f'dy[{i}]=mass*{num(volume/sum(volumes))};dy[{i+1}]=energy*{num(volume/sum(volumes))};') + dependencies.assign(f'dy[{i}]', (f'dy[{j}]' for j in offsets)) + dependencies.assign(f'dy[{i+1}]', (f'dy[{j+1}]' for j in offsets)) lines.append('}') missing=set(slots)-assigned if missing: raise NativeCapabilityError(f'Native output mapping incomplete: {sorted(missing)}') if not state_keys: lines.append('dy[0]=0;') + jacobian = dependencies.build() np,ng,nq=max(1,len(pgroups)),max(1,gas_count),max(1,len(pneu)) source='\n'.join(['#include "model.h"','#include ',*declarations, f'const NativeStop model_stops[{max(1,len(stops))}] = {{'+(','.join('{'+str(s[0])+','+','.join(num(v) for v in s[1:])+'}' for s in stops) or '{0,0,0,0,0,0,0}')+'};', 'const double model_atol[NSTATES] = {'+','.join(map(state_absolute_tolerance, state_keys or ['dummy']))+'};', 'const char *const model_output_keys[NOUTPUTS] = {'+(','.join(json.dumps(v.key,ensure_ascii=True) for v in variables) or '""')+'};', + *jacobian.source_lines(), *schedule_helpers, 'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}', - 'int model_eval(double t,const double *y,double *dy,double *w) {', + 'static int model_eval_internal(double t,const double *y,double *dy,double *w,int canonical) {', f'NativePropertyState property_states[{min(256,max(16,4*gas_count+2*len(components)))}];', 'NativePropertyCache property_cache, *properties=&property_cache;', 'native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));', + 'NativePropertyCache *gas_properties=canonical?NULL:properties;(void)gas_properties;', *(['double projected[NSTATES];for(int i=0;i> column & 1) + for row in range(self.state_count)) + conflicts = [set() for _ in range(self.state_count)] + for entries in rows: + for column in entries: + conflicts[column].update(set(entries) - {column}) + colors = {} + while len(colors) < self.state_count: + column = max((i for i in range(self.state_count) if i not in colors), + key=lambda i: (len({colors[j] for j in conflicts[i] if j in colors}), + len(conflicts[i]), -i)) + used = {colors[j] for j in conflicts[column] if j in colors} + color = 0 + while color in used: + color += 1 + colors[column] = color + ordered = tuple(colors[i] for i in range(self.state_count)) + for entries in rows: + if len({ordered[column] for column in entries}) != len(entries): + raise AssertionError('Jacobian coloring contains a row conflict') + return JacobianStructure(self.state_count, rows, ordered) diff --git a/app/simulation/native_codegen/runner.py b/app/simulation/native_codegen/runner.py index 176f170..dafdb22 100644 --- a/app/simulation/native_codegen/runner.py +++ b/app/simulation/native_codegen/runner.py @@ -41,6 +41,11 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa command.append("--solve-only") creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 started = time.perf_counter() + # A fast worker may finish before the monitor's first iteration. Honor a + # cancellation already requested during preparation before spawning it. + cancelled_at = started if cancel_check is not None and cancel_check() else None + if cancelled_at is not None: + cancel_path.write_text("cancel\n", encoding="ascii") process = subprocess.Popen(command, cwd=build.executable.parent, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", creationflags=creationflags) @@ -53,7 +58,6 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa reader.start() if activity_tracker is not None: activity_tracker.start_integration(config.t_start) - cancelled_at = None last_time = config.t_start try: with (run_dir / "worker.log").open("w", encoding="utf-8") as log: diff --git a/docs/other/assets/2026-09-11/jacobian-cost-summary.json b/docs/other/assets/2026-09-11/jacobian-cost-summary.json new file mode 100644 index 0000000..2ff40dd --- /dev/null +++ b/docs/other/assets/2026-09-11/jacobian-cost-summary.json @@ -0,0 +1,1488 @@ +{ + "schemaVersion": 1, + "baselineCommit": "3bc4be3c061898d130be9b2b3cd633e9573acf04", + "numericalAcceptance": "Experimental opt-in; no equal-accuracy acceptance; production default remains dense", + "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." + }, + "controlComparisons": { + "ready": { + "metric": "frontend.clickToReadyDomMs", + "unit": "ms", + "statistic": "ratio_of_group_medians", + "baseline": { + "n": 3, + "missing": 0, + "min": 6999, + "median": 7018.5, + "max": 7304 + }, + "optimized": { + "n": 3, + "missing": 0, + "min": 3435.400001525879, + "median": 3455.6000003814697, + "max": 3639.3999996185303 + }, + "saved": 3562.8999996185303, + "durationReductionPercent": 50.764408343927194, + "speedupRatio": 2.0310510473507395 + }, + "saved_observed": { + "metric": "frontend.clickToIndexedDbObservedMs", + "unit": "ms", + "statistic": "ratio_of_group_medians", + "baseline": { + "n": 3, + "missing": 0, + "min": 7086.900001525879, + "median": 7159.700000762939, + "max": 7412.400001525879 + }, + "optimized": { + "n": 3, + "missing": 0, + "min": 3567.699998855591, + "median": 3579.300001144409, + "max": 3732.2000007629395 + }, + "saved": 3580.3999996185303, + "durationReductionPercent": 50.0076818754557, + "speedupRatio": 2.0003073222344505 + }, + "csv_download_saved": { + "metric": "frontend.csvClickToDownloadSavedMs", + "unit": "ms", + "statistic": "ratio_of_group_medians", + "baseline": { + "n": 3, + "missing": 0, + "min": 906.6000003814697, + "median": 1017.2999992370605, + "max": 1268.5 + }, + "optimized": { + "n": 3, + "missing": 0, + "min": 1038.6000003814697, + "median": 1119.3000011444092, + "max": 1284.5 + }, + "saved": -102.00000190734863, + "durationReductionPercent": -10.026541038419843, + "speedupRatio": 0.9088716145778072 + } + }, + "browserGroups": { + "baseline": { + "source": "browser-baseline/summary.json", + "mode": "control", + "inputSha256": "670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288", + "buildAssetSetSha256": "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2", + "browser": "151.0.7922.34", + "node": "v24.18.0", + "cache": { + "formalHits": 3, + "formalCount": 3, + "coldRuns": [ + { + "run": 0, + "warmup": true, + "buildSeconds": 3.878522366285324 + } + ] + }, + "formal": { + "frontend.clickToFetchMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.clickToReadyDomMs": { + "n": 3, + "missing": 0, + "min": 6999, + "median": 7018.5, + "max": 7304 + }, + "frontend.clickToReadyPaintOpportunityMs": { + "n": 3, + "missing": 0, + "min": 7016.200000762939, + "median": 7080, + "max": 7334.700000762939 + }, + "frontend.clickToIndexedDbCommitMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.clickToIndexedDbObservedMs": { + "n": 3, + "missing": 0, + "min": 7086.900001525879, + "median": 7159.700000762939, + "max": 7412.400001525879 + }, + "frontend.resultTabToDomMs": { + "n": 3, + "missing": 0, + "min": 128.19999885559082, + "median": 130.79999923706055, + "max": 149.10000038146973 + }, + "frontend.resultTabToPaintOpportunityMs": { + "n": 3, + "missing": 0, + "min": 193, + "median": 201.39999961853027, + "max": 212.89999961853027 + }, + "frontend.curveSelectToDomMs": { + "n": 3, + "missing": 0, + "min": 19.600000381469727, + "median": 21, + "max": 21.399999618530273 + }, + "frontend.csvClickToDownloadSavedMs": { + "n": 3, + "missing": 0, + "min": 906.6000003814697, + "median": 1017.2999992370605, + "max": 1268.5 + }, + "frontend.resultClickToDownloadSavedMs": { + "n": 3, + "missing": 0, + "min": 1139.8999996185303, + "median": 1359, + "max": 1374.900001525879 + }, + "frontend.resultParseMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.synchronousStreamDecodeMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.resultParseEndToReadyDomMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.csvClickToBlobAnchorMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.csvWorkerStartToCompleteReceivedMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.restoreNavigationToDomMs": { + "n": 3, + "missing": 0, + "min": 265.3999996185303, + "median": 272.0999984741211, + "max": 274.29999923706055 + }, + "native.solveSeconds": { + "n": 3, + "missing": 0, + "min": 6.154733393341303, + "median": 6.186589628458023, + "max": 6.1948783956468105 + }, + "native.buildSeconds": { + "n": 3, + "missing": 0, + "min": 0.0335598886013031, + "median": 0.03419310972094536, + "max": 0.04274878837168217 + }, + "native.nfev": { + "n": 3, + "missing": 0, + "min": 74265, + "median": 74265, + "max": 74265 + }, + "native.njev": { + "n": 3, + "missing": 0, + "min": 475, + "median": 475, + "max": 475 + }, + "native.nlu": { + "n": 3, + "missing": 0, + "min": 1656, + "median": 1656, + "max": 1656 + }, + "native.jacobianRhsCalls": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "native.cvodeLinearRhsCalls": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "native.acceptedSteps": { + "n": 3, + "missing": 0, + "min": 6974, + "median": 6974, + "max": 6974 + }, + "native.rejectedSteps": { + "n": 3, + "missing": 0, + "min": 454, + "median": 454, + "max": 454 + } + }, + "warmup": { + "native.buildSeconds": { + "n": 1, + "missing": 0, + "min": 3.878522366285324, + "median": 3.878522366285324, + "max": 3.878522366285324 + }, + "frontend.clickToReadyDomMs": { + "n": 1, + "missing": 0, + "min": 10909.89999961853, + "median": 10909.89999961853, + "max": 10909.89999961853 + } + } + }, + "optimized": { + "source": "browser-optimized/summary.json", + "mode": "control", + "inputSha256": "670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288", + "buildAssetSetSha256": "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2", + "browser": "151.0.7922.34", + "node": "v24.18.0", + "cache": { + "formalHits": 3, + "formalCount": 3, + "coldRuns": [ + { + "run": 0, + "warmup": true, + "buildSeconds": 3.9581447523087263 + } + ] + }, + "formal": { + "frontend.clickToFetchMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.clickToReadyDomMs": { + "n": 3, + "missing": 0, + "min": 3435.400001525879, + "median": 3455.6000003814697, + "max": 3639.3999996185303 + }, + "frontend.clickToReadyPaintOpportunityMs": { + "n": 3, + "missing": 0, + "min": 3456.300001144409, + "median": 3469.199998855591, + "max": 3656.1000003814697 + }, + "frontend.clickToIndexedDbCommitMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.clickToIndexedDbObservedMs": { + "n": 3, + "missing": 0, + "min": 3567.699998855591, + "median": 3579.300001144409, + "max": 3732.2000007629395 + }, + "frontend.resultTabToDomMs": { + "n": 3, + "missing": 0, + "min": 124.0999984741211, + "median": 127, + "max": 135.4000015258789 + }, + "frontend.resultTabToPaintOpportunityMs": { + "n": 3, + "missing": 0, + "min": 182.89999961853027, + "median": 184.39999961853027, + "max": 198.5 + }, + "frontend.curveSelectToDomMs": { + "n": 3, + "missing": 0, + "min": 16.899999618530273, + "median": 17.100000381469727, + "max": 17.200000762939453 + }, + "frontend.csvClickToDownloadSavedMs": { + "n": 3, + "missing": 0, + "min": 1038.6000003814697, + "median": 1119.3000011444092, + "max": 1284.5 + }, + "frontend.resultClickToDownloadSavedMs": { + "n": 3, + "missing": 0, + "min": 1115.3000011444092, + "median": 1287.6000003814697, + "max": 1388.7000007629395 + }, + "frontend.resultParseMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.synchronousStreamDecodeMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.resultParseEndToReadyDomMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.csvClickToBlobAnchorMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.csvWorkerStartToCompleteReceivedMs": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "frontend.restoreNavigationToDomMs": { + "n": 3, + "missing": 0, + "min": 259.6999988555908, + "median": 267.3000011444092, + "max": 267.79999923706055 + }, + "native.solveSeconds": { + "n": 3, + "missing": 0, + "min": 2.584333833307028, + "median": 2.5846563447266817, + "max": 2.6528533585369587 + }, + "native.buildSeconds": { + "n": 3, + "missing": 0, + "min": 0.03163902834057808, + "median": 0.033987103030085564, + "max": 0.03531844727694988 + }, + "native.nfev": { + "n": 3, + "missing": 0, + "min": 22853, + "median": 22853, + "max": 22853 + }, + "native.njev": { + "n": 3, + "missing": 0, + "min": 433, + "median": 433, + "max": 433 + }, + "native.nlu": { + "n": 3, + "missing": 0, + "min": 1404, + "median": 1404, + "max": 1404 + }, + "native.jacobianRhsCalls": { + "n": 3, + "missing": 0, + "min": 12124, + "median": 12124, + "max": 12124 + }, + "native.cvodeLinearRhsCalls": { + "n": 3, + "missing": 0, + "min": 0, + "median": 0, + "max": 0 + }, + "native.acceptedSteps": { + "n": 3, + "missing": 0, + "min": 6660, + "median": 6660, + "max": 6660 + }, + "native.rejectedSteps": { + "n": 3, + "missing": 0, + "min": 359, + "median": 359, + "max": 359 + } + }, + "warmup": { + "native.buildSeconds": { + "n": 1, + "missing": 0, + "min": 3.9581447523087263, + "median": 3.9581447523087263, + "max": 3.9581447523087263 + }, + "frontend.clickToReadyDomMs": { + "n": 1, + "missing": 0, + "min": 7465.5, + "median": 7465.5, + "max": 7465.5 + } + } + }, + "baseline-profiled": { + "source": "browser-baseline-profiled/summary.json", + "mode": "profiled", + "inputSha256": "670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288", + "buildAssetSetSha256": "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2", + "browser": "151.0.7922.34", + "node": "v24.18.0", + "cache": { + "formalHits": 3, + "formalCount": 3, + "coldRuns": [ + { + "run": 0, + "warmup": true, + "buildSeconds": 4.257864186540246 + } + ] + }, + "formal": { + "frontend.clickToFetchMs": { + "n": 3, + "missing": 0, + "min": 18.19999885559082, + "median": 18.799999237060547, + "max": 31.19999885559082 + }, + "frontend.clickToReadyDomMs": { + "n": 3, + "missing": 0, + "min": 6863.099998474121, + "median": 6876.39999961853, + "max": 6969.799999237061 + }, + "frontend.clickToReadyPaintOpportunityMs": { + "n": 3, + "missing": 0, + "min": 6879.599998474121, + "median": 6899.39999961853, + "max": 6996.5 + }, + "frontend.clickToIndexedDbCommitMs": { + "n": 3, + "missing": 0, + "min": 6956.299999237061, + "median": 6975.10000038147, + "max": 7079.799999237061 + }, + "frontend.clickToIndexedDbObservedMs": { + "n": 3, + "missing": 0, + "min": 6975.299999237061, + "median": 6975.699998855591, + "max": 7080 + }, + "frontend.resultTabToDomMs": { + "n": 3, + "missing": 0, + "min": 119.39999961853027, + "median": 119.5, + "max": 142.0999984741211 + }, + "frontend.resultTabToPaintOpportunityMs": { + "n": 3, + "missing": 0, + "min": 177.39999961853027, + "median": 181.69999885559082, + "max": 203.69999885559082 + }, + "frontend.curveSelectToDomMs": { + "n": 3, + "missing": 0, + "min": 20.19999885559082, + "median": 21.200000762939453, + "max": 21.5 + }, + "frontend.csvClickToDownloadSavedMs": { + "n": 3, + "missing": 0, + "min": 900.1999988555908, + "median": 1225, + "max": 1232.3000011444092 + }, + "frontend.resultClickToDownloadSavedMs": { + "n": 3, + "missing": 0, + "min": 1139.5, + "median": 1472.3999996185303, + "max": 1591.2000007629395 + }, + "frontend.resultParseMs": { + "n": 3, + "missing": 0, + "min": 104.5, + "median": 108.70000076293945, + "max": 125.29999923706055 + }, + "frontend.synchronousStreamDecodeMs": { + "n": 3, + "missing": 0, + "min": 27.30000114440918, + "median": 30.600011825561523, + "max": 30.90000343322754 + }, + "frontend.resultParseEndToReadyDomMs": { + "n": 3, + "missing": 0, + "min": 45.10000038146973, + "median": 56.69999885559082, + "max": 77.70000076293945 + }, + "frontend.csvClickToBlobAnchorMs": { + "n": 3, + "missing": 0, + "min": 481.29999923706055, + "median": 487.1000003814697, + "max": 512.8999996185303 + }, + "frontend.csvWorkerStartToCompleteReceivedMs": { + "n": 3, + "missing": 0, + "min": 455.8999996185303, + "median": 459.70000076293945, + "max": 480.5 + }, + "frontend.restoreNavigationToDomMs": { + "n": 3, + "missing": 0, + "min": 268.29999923706055, + "median": 292.1999988555908, + "max": 324.20000076293945 + }, + "backend.httpTotalMs": { + "n": 3, + "missing": 0, + "min": 6574.380322, + "median": 6582.457385, + "max": 6639.697444 + }, + "backendSpan.xml_validation": { + "n": 3, + "missing": 0, + "min": 19.817843000000003, + "median": 23.137652, + "max": 24.614099 + }, + "backendSpan.network_compilation": { + "n": 3, + "missing": 0, + "min": 36.37898199999999, + "median": 40.267707, + "max": 41.85826 + }, + "backendSpan.c_generation": { + "n": 3, + "missing": 0, + "min": 54.753178000000005, + "median": 62.82534100000001, + "max": 90.29663200000002 + }, + "backendSpan.native_build_or_cache_validation": { + "n": 3, + "missing": 0, + "min": 34.43911800000001, + "median": 34.640525, + "max": 35.062039 + }, + "backendSpan.native_indexed_result_read": { + "n": 3, + "missing": 0, + "min": 33.13457599999947, + "median": 41.52314899999965, + "max": 42.91529399999945 + }, + "backendSpan.native_result_metadata_json_parse": { + "n": 3, + "missing": 0, + "min": 1.0713349999996353, + "median": 1.155135999999402, + "max": 2.1820709999992687 + }, + "backendSpan.response_result_json_serialization": { + "n": 3, + "missing": 0, + "min": 27.720500999999786, + "median": 32.836866000000555, + "max": 33.48168700000042 + }, + "cWall.integrationSeconds": { + "n": 3, + "missing": 0, + "min": 5.991004675626755, + "median": 6.0229658130556345, + "max": 6.047124598175287 + }, + "cWall.projectionSeconds": { + "n": 3, + "missing": 0, + "min": 0.07681819424033165, + "median": 0.08062181994318962, + "max": 0.08308219909667969 + }, + "cWall.jsonWriteSeconds": { + "n": 3, + "missing": 0, + "min": 0.16539897210896015, + "median": 0.1660968940705061, + "max": 0.16900468990206718 + }, + "cWall.mainTotalSeconds": { + "n": 3, + "missing": 0, + "min": 6.234305376186967, + "median": 6.273725759238005, + "max": 6.2974305264651775 + }, + "cCpu.projectionCpuSeconds": { + "n": 3, + "missing": 0, + "min": 0.07681800000000027, + "median": 0.08062099999999983, + "max": 0.08304899999999993 + }, + "cCpu.jsonWriteCpuSeconds": { + "n": 3, + "missing": 0, + "min": 0.16535800000000034, + "median": 0.16605500000000006, + "max": 0.1689999999999996 + }, + "native.solveSeconds": { + "n": 3, + "missing": 0, + "min": 5.991004675626755, + "median": 6.0229658130556345, + "max": 6.047124598175287 + }, + "native.buildSeconds": { + "n": 3, + "missing": 0, + "min": 0.03400060534477234, + "median": 0.03425261192023754, + "max": 0.03470412641763687 + }, + "native.nfev": { + "n": 3, + "missing": 0, + "min": 74265, + "median": 74265, + "max": 74265 + }, + "native.njev": { + "n": 3, + "missing": 0, + "min": 475, + "median": 475, + "max": 475 + }, + "native.nlu": { + "n": 3, + "missing": 0, + "min": 1656, + "median": 1656, + "max": 1656 + }, + "native.jacobianRhsCalls": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "native.cvodeLinearRhsCalls": { + "n": 0, + "missing": 3, + "min": null, + "median": null, + "max": null + }, + "native.acceptedSteps": { + "n": 3, + "missing": 0, + "min": 6974, + "median": 6974, + "max": 6974 + }, + "native.rejectedSteps": { + "n": 3, + "missing": 0, + "min": 454, + "median": 454, + "max": 454 + } + }, + "warmup": { + "native.buildSeconds": { + "n": 1, + "missing": 0, + "min": 4.257864186540246, + "median": 4.257864186540246, + "max": 4.257864186540246 + }, + "frontend.clickToReadyDomMs": { + "n": 1, + "missing": 0, + "min": 11086.39999961853, + "median": 11086.39999961853, + "max": 11086.39999961853 + } + } + }, + "optimized-profiled": { + "source": "browser-optimized-profiled/summary.json", + "mode": "profiled", + "inputSha256": "670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288", + "buildAssetSetSha256": "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2", + "browser": "151.0.7922.34", + "node": "v24.18.0", + "cache": { + "formalHits": 3, + "formalCount": 3, + "coldRuns": [ + { + "run": 0, + "warmup": true, + "buildSeconds": 4.131010768935084 + } + ] + }, + "formal": { + "frontend.clickToFetchMs": { + "n": 3, + "missing": 0, + "min": 18.80000114440918, + "median": 22.100000381469727, + "max": 22.899999618530273 + }, + "frontend.clickToReadyDomMs": { + "n": 3, + "missing": 0, + "min": 3528.800001144409, + "median": 3537.2000007629395, + "max": 3566 + }, + "frontend.clickToReadyPaintOpportunityMs": { + "n": 3, + "missing": 0, + "min": 3545.900001525879, + "median": 3554.6000003814697, + "max": 3583.400001525879 + }, + "frontend.clickToIndexedDbCommitMs": { + "n": 3, + "missing": 0, + "min": 3616.7000007629395, + "median": 3644.8999996185303, + "max": 3691.6000003814697 + }, + "frontend.clickToIndexedDbObservedMs": { + "n": 3, + "missing": 0, + "min": 3641.900001525879, + "median": 3645.1000003814697, + "max": 3691.7000007629395 + }, + "frontend.resultTabToDomMs": { + "n": 3, + "missing": 0, + "min": 124.19999885559082, + "median": 126.39999961853027, + "max": 135 + }, + "frontend.resultTabToPaintOpportunityMs": { + "n": 3, + "missing": 0, + "min": 182.29999923706055, + "median": 189, + "max": 196.19999885559082 + }, + "frontend.curveSelectToDomMs": { + "n": 3, + "missing": 0, + "min": 17.700000762939453, + "median": 19.600000381469727, + "max": 24.5 + }, + "frontend.csvClickToDownloadSavedMs": { + "n": 3, + "missing": 0, + "min": 981.5999984741211, + "median": 1070.3999996185303, + "max": 1514.8000011444092 + }, + "frontend.resultClickToDownloadSavedMs": { + "n": 3, + "missing": 0, + "min": 1170.3999996185303, + "median": 1199.400001525879, + "max": 1262.7999992370605 + }, + "frontend.resultParseMs": { + "n": 3, + "missing": 0, + "min": 103.19999885559082, + "median": 103.39999961853027, + "max": 107.89999961853027 + }, + "frontend.synchronousStreamDecodeMs": { + "n": 3, + "missing": 0, + "min": 24.40001106262207, + "median": 28, + "max": 30.999996185302734 + }, + "frontend.resultParseEndToReadyDomMs": { + "n": 3, + "missing": 0, + "min": 37.30000114440918, + "median": 38.5, + "max": 48 + }, + "frontend.csvClickToBlobAnchorMs": { + "n": 3, + "missing": 0, + "min": 491.20000076293945, + "median": 506.6000003814697, + "max": 509.5 + }, + "frontend.csvWorkerStartToCompleteReceivedMs": { + "n": 3, + "missing": 0, + "min": 458.1000003814697, + "median": 476.70000076293945, + "max": 478.1000003814697 + }, + "frontend.restoreNavigationToDomMs": { + "n": 3, + "missing": 0, + "min": 265.70000076293945, + "median": 287.79999923706055, + "max": 309 + }, + "backend.httpTotalMs": { + "n": 3, + "missing": 0, + "min": 3274.353344, + "median": 3293.63067, + "max": 3305.754264 + }, + "backendSpan.xml_validation": { + "n": 3, + "missing": 0, + "min": 22.002114000000002, + "median": 22.740438, + "max": 24.141884 + }, + "backendSpan.network_compilation": { + "n": 3, + "missing": 0, + "min": 35.341048, + "median": 38.929664, + "max": 72.076841 + }, + "backendSpan.c_generation": { + "n": 3, + "missing": 0, + "min": 91.785781, + "median": 92.98732, + "max": 93.50783700000001 + }, + "backendSpan.native_build_or_cache_validation": { + "n": 3, + "missing": 0, + "min": 34.447619, + "median": 34.77212900000001, + "max": 36.550287 + }, + "backendSpan.native_indexed_result_read": { + "n": 3, + "missing": 0, + "min": 28.538526000000275, + "median": 45.05386300000009, + "max": 47.159732000000076 + }, + "backendSpan.native_result_metadata_json_parse": { + "n": 3, + "missing": 0, + "min": 1.2262399999999616, + "median": 1.3418429999996988, + "max": 1.359443999999712 + }, + "backendSpan.response_result_json_serialization": { + "n": 3, + "missing": 0, + "min": 24.105782999999974, + "median": 24.171384999999646, + "max": 33.10307499999999 + }, + "cWall.integrationSeconds": { + "n": 3, + "missing": 0, + "min": 2.6579617261886597, + "median": 2.662376467138529, + "max": 2.6725326981395483 + }, + "cWall.projectionSeconds": { + "n": 3, + "missing": 0, + "min": 0.08070902153849602, + "median": 0.08097222819924355, + "max": 0.08253358118236065 + }, + "cWall.jsonWriteSeconds": { + "n": 3, + "missing": 0, + "min": 0.1650443598628044, + "median": 0.16764044389128685, + "max": 0.16780925169587135 + }, + "cWall.mainTotalSeconds": { + "n": 3, + "missing": 0, + "min": 2.9050150476396084, + "median": 2.913610829040408, + "max": 2.9221549071371555 + }, + "cCpu.projectionCpuSeconds": { + "n": 3, + "missing": 0, + "min": 0.080708, + "median": 0.08097200000000004, + "max": 0.08246199999999959 + }, + "cCpu.jsonWriteCpuSeconds": { + "n": 3, + "missing": 0, + "min": 0.1650400000000003, + "median": 0.16759900000000005, + "max": 0.167713 + }, + "native.solveSeconds": { + "n": 3, + "missing": 0, + "min": 2.6579617261886597, + "median": 2.662376467138529, + "max": 2.6725326981395483 + }, + "native.buildSeconds": { + "n": 3, + "missing": 0, + "min": 0.03401860594749451, + "median": 0.03430301323533058, + "max": 0.036061473190784454 + }, + "native.nfev": { + "n": 3, + "missing": 0, + "min": 22853, + "median": 22853, + "max": 22853 + }, + "native.njev": { + "n": 3, + "missing": 0, + "min": 433, + "median": 433, + "max": 433 + }, + "native.nlu": { + "n": 3, + "missing": 0, + "min": 1404, + "median": 1404, + "max": 1404 + }, + "native.jacobianRhsCalls": { + "n": 3, + "missing": 0, + "min": 12124, + "median": 12124, + "max": 12124 + }, + "native.cvodeLinearRhsCalls": { + "n": 3, + "missing": 0, + "min": 0, + "median": 0, + "max": 0 + }, + "native.acceptedSteps": { + "n": 3, + "missing": 0, + "min": 6660, + "median": 6660, + "max": 6660 + }, + "native.rejectedSteps": { + "n": 3, + "missing": 0, + "min": 359, + "median": 359, + "max": 359 + } + }, + "warmup": { + "native.buildSeconds": { + "n": 1, + "missing": 0, + "min": 4.131010768935084, + "median": 4.131010768935084, + "max": 4.131010768935084 + }, + "frontend.clickToReadyDomMs": { + "n": 1, + "missing": 0, + "min": 7714.200000762939, + "median": 7714.200000762939, + "max": 7714.200000762939 + } + } + } + }, + "nativeTiming": { + "method": "Alternating serial pairs, same executable; paired ratios and ratio of group medians are distinct estimates. Warmups and verify excluded.", + "metrics": { + "solveSeconds": { + "pairs": [ + { + "pair": 2, + "dense": 5.9121952168643475, + "auto": 2.5543627608567476, + "reductionPercent": 56.795020002544746, + "speedup": 2.314547999001271 + }, + { + "pair": 3, + "dense": 5.656100198626518, + "auto": 2.4254562724381685, + "reductionPercent": 57.117869428353714, + "speedup": 2.3319736838383704 + }, + { + "pair": 4, + "dense": 5.791500395163894, + "auto": 2.492558753117919, + "reductionPercent": 56.96177876117745, + "speedup": 2.3235161008418994 + } + ], + "pairedReductionPercent": { + "n": 3, + "median": 56.96177876117745, + "min": 56.795020002544746, + "max": 57.117869428353714, + "values": [ + 56.795020002544746, + 57.117869428353714, + 56.96177876117745 + ] + }, + "pairedSpeedup": { + "n": 3, + "median": 2.3235161008418994, + "min": 2.314547999001271, + "max": 2.3319736838383704, + "values": [ + 2.314547999001271, + 2.3319736838383704, + 2.3235161008418994 + ] + }, + "ratioOfGroupMedians": { + "denseMedian": 5.791500395163894, + "autoMedian": 2.492558753117919, + "reductionPercent": 56.96177876117745, + "speedup": 2.3235161008418994 + } + }, + "solveCpuSeconds": { + "pairs": [ + { + "pair": 2, + "dense": 5.910715000000001, + "auto": 2.553789, + "reductionPercent": 56.793907336083706, + "speedup": 2.314488393520373 + }, + { + "pair": 3, + "dense": 5.655141, + "auto": 2.4249300000000003, + "reductionPercent": 57.119902050187605, + "speedup": 2.3320842251116525 + }, + { + "pair": 4, + "dense": 5.7901679999999995, + "auto": 2.481718, + "reductionPercent": 57.13910200878455, + "speedup": 2.3331289050569 + } + ], + "pairedReductionPercent": { + "n": 3, + "median": 57.119902050187605, + "min": 56.793907336083706, + "max": 57.13910200878455, + "values": [ + 56.793907336083706, + 57.119902050187605, + 57.13910200878455 + ] + }, + "pairedSpeedup": { + "n": 3, + "median": 2.3320842251116525, + "min": 2.314488393520373, + "max": 2.3331289050569, + "values": [ + 2.314488393520373, + 2.3320842251116525, + 2.3331289050569 + ] + }, + "ratioOfGroupMedians": { + "denseMedian": 5.7901679999999995, + "autoMedian": 2.481718, + "reductionPercent": 57.13910200878455, + "speedup": 2.3331289050569 + } + }, + "processWallSeconds": { + "pairs": [ + { + "pair": 2, + "dense": 6.239419741556048, + "auto": 2.8775970581918955, + "reductionPercent": 53.880373858703535, + "speedup": 2.168274298096661 + }, + { + "pair": 3, + "dense": 5.992330618202686, + "auto": 2.726874863728881, + "reductionPercent": 54.49391835214246, + "speedup": 2.197508473127527 + }, + { + "pair": 4, + "dense": 6.091389335691929, + "auto": 2.827176822349429, + "reductionPercent": 53.58732357191734, + "speedup": 2.154583783914119 + } + ], + "pairedReductionPercent": { + "n": 3, + "median": 53.880373858703535, + "min": 53.58732357191734, + "max": 54.49391835214246, + "values": [ + 53.880373858703535, + 54.49391835214246, + 53.58732357191734 + ] + }, + "pairedSpeedup": { + "n": 3, + "median": 2.168274298096661, + "min": 2.154583783914119, + "max": 2.197508473127527, + "values": [ + 2.168274298096661, + 2.197508473127527, + 2.154583783914119 + ] + }, + "ratioOfGroupMedians": { + "denseMedian": 6.091389335691929, + "autoMedian": 2.827176822349429, + "reductionPercent": 53.58732357191734, + "speedup": 2.154583783914119 + } + } + } + }, + "nativeCounters": { + "dense": { + "jacobianMode": "dense-difference", + "jacobianRhsCalls": 0, + "jacobianColoredEvals": 0, + "jacobianFallbacks": 0, + "jacobianChecks": 0, + "jacobianMismatches": 0, + "cvodeRhsCalls": 11565, + "cvodeLinearRhsCalls": 62700, + "nfev": 74265, + "njev": 475, + "nlu": 1656, + "acceptedSteps": 6974, + "rejectedSteps": 454, + "stateTransitions": 1, + "solverStarts": 4 + }, + "auto": { + "jacobianMode": "colored-difference", + "jacobianRhsCalls": 12124, + "jacobianColoredEvals": 433, + "jacobianFallbacks": 0, + "jacobianChecks": 0, + "jacobianMismatches": 0, + "cvodeRhsCalls": 10729, + "cvodeLinearRhsCalls": 0, + "nfev": 22853, + "njev": 433, + "nlu": 1404, + "acceptedSteps": 6660, + "rejectedSteps": 359, + "stateTransitions": 1, + "solverStarts": 4 + }, + "verify": { + "jacobianMode": "colored-difference", + "jacobianRhsCalls": 69280, + "jacobianColoredEvals": 433, + "jacobianFallbacks": 0, + "jacobianChecks": 433, + "jacobianMismatches": 0, + "cvodeRhsCalls": 10729, + "cvodeLinearRhsCalls": 0, + "nfev": 80009, + "njev": 433, + "nlu": 1404, + "acceptedSteps": 6660, + "rejectedSteps": 359, + "stateTransitions": 1, + "solverStarts": 4 + } + }, + "nativeComputeProfile": "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.", + "sources": { + "cost-summary.json": { + "sha256": "a723bb167408de825455ccca6affbbf572fbaee401c1376443f30c3c368f410b", + "bytes": 456606 + }, + "benchmark/summary.json": { + "sha256": "f9296f0317fc3eb546eb733e17ea22dd1041ef7a775651c0adfec936bb2e8311", + "bytes": 2396085 + }, + "native-compute-profile/summary.json": { + "sha256": "79d08dcdffebc13f8a6ef835f88fc489c193fc673943ac701bfc9515d5c72d90", + "bytes": 25582 + }, + "equality-baseline.json": { + "sha256": "08bd1620683cafa21753f574da8b4c0623d1e3a38d6734dd36440e9c8a14604a", + "bytes": 8736 + }, + "equality-optimized.json": { + "sha256": "21faba2c340e02d04724108ab36fba0be07de3f6d165f1294383bfc8721b5818", + "bytes": 8755 + }, + "convergence/convergence-summary.json": { + "sha256": "5eb93f1d1904e1bf1440d859c5df352aef0938500c4a6e3cfd7eb35d122b3619", + "bytes": 88556 + }, + "xml-generated-program-parity.json": { + "sha256": "024457aebd9fe6844295ae885313a0c0475207a0f4a5e950b0d1bc1cca10dc21", + "bytes": 3810 + } + }, + "browserValueChecks": { + "baseline": { + "nativeSha256": "14cf0f8bd88a9ee9ff8e04275b2b56fc094db59857b2ccd42c0f9394ec5b5916", + "comparison": "Numeric equality using == on parsed numbers, with identical keys and lengths; no tolerance, conversion, interpolation or rounding. Diagnostic timings are excluded.", + "sampleCount": 1002, + "seriesCountIncludingTime": 1785, + "finalValueCount": 1784, + "allPassed": true, + "inputSha256": "670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288", + "buildAssetSetSha256": "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2", + "buildAssetSetSha256Values": [ + "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2" + ], + "allGroupsCsvFilesByteIdentical": true, + "allCsvFilesByteIdentical": true, + "csvSha256": "9539539330a5641e0b2e4c6eadf5d64e8073f3d6611a310d5c0951fd9369c17b", + "totalCsvCellsCompared": 14308560, + "totalSeriesValuesCompared": 14308560, + "totalFinalValuesCompared": 14272 + }, + "optimized": { + "nativeSha256": "3aa962aa2d9145850b0e2e882e447bafd8c5c5904d79bf4fda951285ca838a2f", + "comparison": "Numeric equality using == on parsed numbers, with identical keys and lengths; no tolerance, conversion, interpolation or rounding. Diagnostic timings are excluded.", + "sampleCount": 1002, + "seriesCountIncludingTime": 1785, + "finalValueCount": 1784, + "allPassed": true, + "inputSha256": "670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288", + "buildAssetSetSha256": "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2", + "buildAssetSetSha256Values": [ + "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2" + ], + "allGroupsCsvFilesByteIdentical": true, + "allCsvFilesByteIdentical": true, + "csvSha256": "9bf943659ecb717ea9c198e544039828ee072f3ca4d14490071d7462ec563c65", + "totalCsvCellsCompared": 14308560, + "totalSeriesValuesCompared": 14308560, + "totalFinalValuesCompared": 14272 + } + }, + "xmlGeneratedProgramParity": { + "allGeneratedSourceEqual": true, + "allGeneratedHeaderEqual": true, + "xmlByteHashesDiffer": true, + "runs": [ + { + "xml": "benchmark/input.xml", + "xmlSha256": "2804b33f04cabb3c10fcc3cc0843c35de17efbe1b6ee5e5e821edd9a5515a23d", + "generatedSourceSha256": "e1b12735cda177dee48bac333509528e683b28aa820d5ff7aba6ed58cb2f087b", + "generatedHeaderSha256": "e21a80a76bd9a5a195bee24de59da8cc351fe6548932b024e276c1ac94bf49a6" + }, + { + "xml": "backend-optimized-profiled/requests/2a47f68a-45a7-498f-8c37-4ce5e011c9a7/input.xml", + "xmlSha256": "ad8f102935b583fe6eec6a372449cb8d1d847c34d0091ea1aa0d23a2b840e099", + "generatedSourceSha256": "e1b12735cda177dee48bac333509528e683b28aa820d5ff7aba6ed58cb2f087b", + "generatedHeaderSha256": "e21a80a76bd9a5a195bee24de59da8cc351fe6548932b024e276c1ac94bf49a6" + }, + { + "xml": "backend-optimized-profiled/requests/9797804b-2e38-4d39-8412-deafd9f36664/input.xml", + "xmlSha256": "ad8f102935b583fe6eec6a372449cb8d1d847c34d0091ea1aa0d23a2b840e099", + "generatedSourceSha256": "e1b12735cda177dee48bac333509528e683b28aa820d5ff7aba6ed58cb2f087b", + "generatedHeaderSha256": "e21a80a76bd9a5a195bee24de59da8cc351fe6548932b024e276c1ac94bf49a6" + }, + { + "xml": "backend-optimized-profiled/requests/b59a4813-47bc-4c08-bf87-12ca20db0fbc/input.xml", + "xmlSha256": "ad8f102935b583fe6eec6a372449cb8d1d847c34d0091ea1aa0d23a2b840e099", + "generatedSourceSha256": "e1b12735cda177dee48bac333509528e683b28aa820d5ff7aba6ed58cb2f087b", + "generatedHeaderSha256": "e21a80a76bd9a5a195bee24de59da8cc351fe6548932b024e276c1ac94bf49a6" + }, + { + "xml": "backend-optimized-profiled/requests/ec31a869-3291-4883-aa1c-47e45e6f390a/input.xml", + "xmlSha256": "ad8f102935b583fe6eec6a372449cb8d1d847c34d0091ea1aa0d23a2b840e099", + "generatedSourceSha256": "e1b12735cda177dee48bac333509528e683b28aa820d5ff7aba6ed58cb2f087b", + "generatedHeaderSha256": "e21a80a76bd9a5a195bee24de59da8cc351fe6548932b024e276c1ac94bf49a6" + }, + { + "xml": "baseline-source/profiled-backend/requests/35334999-3e59-49cc-94e8-c322a2a6b547/input.xml", + "xmlSha256": "ad8f102935b583fe6eec6a372449cb8d1d847c34d0091ea1aa0d23a2b840e099", + "generatedSourceSha256": "e1b12735cda177dee48bac333509528e683b28aa820d5ff7aba6ed58cb2f087b", + "generatedHeaderSha256": "e21a80a76bd9a5a195bee24de59da8cc351fe6548932b024e276c1ac94bf49a6" + }, + { + "xml": "baseline-source/profiled-backend/requests/56e0dad6-928d-4094-a9c6-4f9abc1fb095/input.xml", + "xmlSha256": "ad8f102935b583fe6eec6a372449cb8d1d847c34d0091ea1aa0d23a2b840e099", + "generatedSourceSha256": "e1b12735cda177dee48bac333509528e683b28aa820d5ff7aba6ed58cb2f087b", + "generatedHeaderSha256": "e21a80a76bd9a5a195bee24de59da8cc351fe6548932b024e276c1ac94bf49a6" + }, + { + "xml": "baseline-source/profiled-backend/requests/be9c63aa-410d-4f02-b951-94b281965aa8/input.xml", + "xmlSha256": "ad8f102935b583fe6eec6a372449cb8d1d847c34d0091ea1aa0d23a2b840e099", + "generatedSourceSha256": "e1b12735cda177dee48bac333509528e683b28aa820d5ff7aba6ed58cb2f087b", + "generatedHeaderSha256": "e21a80a76bd9a5a195bee24de59da8cc351fe6548932b024e276c1ac94bf49a6" + }, + { + "xml": "baseline-source/profiled-backend/requests/ea2a21a3-fdd9-4cc5-ba6d-799ce76acb40/input.xml", + "xmlSha256": "ad8f102935b583fe6eec6a372449cb8d1d847c34d0091ea1aa0d23a2b840e099", + "generatedSourceSha256": "e1b12735cda177dee48bac333509528e683b28aa820d5ff7aba6ed58cb2f087b", + "generatedHeaderSha256": "e21a80a76bd9a5a195bee24de59da8cc351fe6548932b024e276c1ac94bf49a6" + } + ], + "note": "All nine XMLs compiled by the current generator. No executable compilation or solve; numerical C/header equality despite XML serialization differences." + } +} diff --git a/docs/other/assets/2026-09-11/jacobian-production-summary.json b/docs/other/assets/2026-09-11/jacobian-production-summary.json new file mode 100644 index 0000000..ef8706b --- /dev/null +++ b/docs/other/assets/2026-09-11/jacobian-production-summary.json @@ -0,0 +1,104 @@ +{ + "acceptance": "User accepted the documented temperature difference and authorized production default; no new Amesim accuracy claim", + "urls": { + "official": "http://127.0.0.1:8000", + "editor": "http://127.0.0.1:5173" + }, + "inputSha256": "670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288", + "frontendAssetSha256": "f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2", + "formalRuns": 3, + "warmups": 1, + "browserErrors": [], + "regression": { + "tests": 57, + "passed": true, + "seconds": 129.667 + }, + "formalMediansMs": { + "importToDomMs": 88.70000076293945, + "importToPaintOpportunityMs": 236.89999961853027, + "clickToFetchMs": null, + "fetchToHeadersMs": null, + "headersToEofMs": null, + "clickToReadyDomMs": 3498.5, + "clickToReadyPaintOpportunityMs": 3521.199998855591, + "resultParseMs": null, + "resultParseEndToReadyDomMs": null, + "clickToIndexedDbCommitMs": null, + "clickToIndexedDbObservedMs": 3613.3999996185303, + "resultTabToDomMs": 132.89999961853027, + "resultTabToPaintOpportunityMs": 195.10000038146973, + "resultTabToDomStableMs": 371.5, + "curveSelectToDomMs": 21.5, + "curveSelectToPaintOpportunityMs": 29.599998474121094, + "curveSelectToDomStableMs": 170.0999984741211, + "csvClickToWorkerConstructMs": null, + "csvWorkerConstructMs": null, + "csvWorkerStartToFinishPostMs": null, + "csvWorkerFinishPostToCompleteReceivedMs": null, + "csvWorkerStartToCompleteReceivedMs": null, + "csvWorkerPostSyncTotalMs": null, + "csvClickToFetchMs": null, + "csvFetchToHeadersMs": null, + "csvClickToBlobAnchorMs": null, + "resultClickToBlobAnchorMs": null, + "csvClickToDownloadSavedMs": 1199.7000007629395, + "resultClickToDownloadSavedMs": 1105.7000007629395, + "streamOutstandingReadMs": null, + "synchronousStreamJsonParseMs": null, + "synchronousStreamDecodeMs": null, + "restoreNavigationToDomMs": 273.1000003814697, + "restoreNavigationToPaintOpportunityMs": 431.1999988555908, + "restoreNavigationToDomStableMs": 606.1000003814697 + }, + "nativeSolveMedianSeconds": 2.606560856103897, + "nativeCounters": { + "buildKey": "6e4d7a46571ce154c1d25dc99d6578025b44d8daefffe953a112f88846e4fb81", + "jacobianMode": "colored-difference", + "nfev": 22853, + "njev": 433, + "jacobianRhsCalls": 12124, + "jacobianColoredEvals": 433, + "jacobianFallbacks": 0, + "cvodeRhsCalls": 10729, + "cvodeLinearRhsCalls": 0, + "acceptedSteps": 6660, + "rejectedSteps": 359, + "solverStarts": 4, + "stateTransitions": 1 + }, + "retiredModeEnvironmentSelectorRemoved": true, + "defaultSelectedWithoutJacobianFlag": true, + "formalPayloadEqualsAcceptedCandidate": true, + "formalCsvCellsCompared": 7154280, + "editorPayloadEqualsAcceptedCandidate": true, + "editorCsvByteEqualsValidatedOfficialCsv": true, + "editorRestoreExact": true, + "csvSha256": "9bf943659ecb717ea9c198e544039828ee072f3ca4d14490071d7462ec563c65", + "sources": { + "regression.log": { + "sha256": "bd240eaf9f4b76a2144176eabf0f3e03d9a376e52554b9062cb45dd4830c7e07", + "bytes": 10282 + }, + "browser-official/summary.json": { + "sha256": "136171c64879b14308238d91b5bf43981c0611e6dc521b5d68c428dd42fcd505", + "bytes": 22672 + }, + "browser-vite/summary.json": { + "sha256": "c996640c02b7535deecf6f48868fa06bbf853e0d9225bde0ce35894dd663fc70", + "bytes": 11303 + }, + "equality-official.json": { + "sha256": "6c0bb3f316693bcf9e4990dee3cf258d8f67f3e578a89a3ac09c4238e27933f3", + "bytes": 4894 + }, + "verify-cli-summary.json": { + "sha256": "5aaa11d8b03dd22683617fa67cebdb49be429942eba7c037533f05e4b63e0551", + "bytes": 1190 + }, + "manual-tool-checks/checks.json": { + "sha256": "1417648d0976c9daf42b912013913621276cd4b017520c9ab2d06f0f7170b0a3", + "bytes": 539 + } + } +} diff --git a/docs/other/assets/2026-09-11/jacobian-time-comparison.svg b/docs/other/assets/2026-09-11/jacobian-time-comparison.svg new file mode 100644 index 0000000..16e10bc --- /dev/null +++ b/docs/other/assets/2026-09-11/jacobian-time-comparison.svg @@ -0,0 +1,2185 @@ + + + + + + + + 2026-09-11T16:07:42.833294 + image/svg+xml + + + Matplotlib v3.6.3, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/other/backups/2026-09-12-result-transfer/README.md b/docs/other/backups/2026-09-12-result-transfer/README.md new file mode 100644 index 0000000..90f94d7 --- /dev/null +++ b/docs/other/backups/2026-09-12-result-transfer/README.md @@ -0,0 +1,25 @@ +# 仿真结果传输优化备份(未启用) + +2026-09-12:按用户要求停止测试,将本轮改动仅保存为后续备选方案。正式源码、依赖声明和浏览器构建资源已恢复到本轮试验前;此前已接受并启用的雅可比求解优化保留。未重启或替换正式后端,未提交或推送本轮备份。 + +## 备份位置 + +- [optimization.patch](optimization.patch):本轮 9 个文件的完整差异,含后端 gzip 中间件、前端结束处理、依赖下限、回归测试及测量工具。 +- [manifest.json](manifest.json):各文件试验前和候选版本的 SHA-256,明确恢复基线。 +- 本机原始试验资料:`test/result-finalization-20260912/`,被 Git 忽略。`baseline-source/` 是试验前源码和构建快照,`candidate-frontend-dist/` 是未启用的候选构建;其余目录保存停止前已产生的浏览器记录和下载。 + +补丁相对于本轮试验开始时、已经包含正式雅可比优化的工作区生成,不能用仓库较早提交直接替代该基线。当前没有需要开启或关闭的生产配置;备份不会被应用自动导入。 + +## 已定位的问题与候选方案 + +`complete` 进度在 C 计算、结果写出和 Python 索引读取完成后发出。旧提示“正在汇总仿真结果”之后仍需传送约 33.4 MB 的 HTTP 结果。用户通过远程端口转发访问 5173,约 20 秒的等待与传输瓶颈相符;受控 1,600,000 B/s 条件已复现约 21 秒等待。这是受控复现,不是用户实际隧道的抓包测量。 + +候选方案只压缩仿真响应:gzip level 1、256 KiB 分块、及时刷新进度;结果体约降至 12.1 MB。JSON、数值精度、采样及求解器保持不变。压缩响应仍读到 EOF,保留浏览器解码完整性校验;未压缩响应可在完整结果行后结束,取消清理不阻塞结果发布。 + +本机已完成组显示压缩增加约 0.45 秒等待,因此该方案主要面向带宽受限场景,不是所有连接的普遍加速。停止测试前已有轻量压缩与 HTTP 回归、前端流结束测试通过;未完成此次正式网页发布及完整八路导出数值的独立验收,不将备份视为已验收版本。 + +## 日后恢复 + +在隔离分支或工作目录中先审阅补丁,执行 `git apply --check docs/other/backups/2026-09-12-result-transfer/optimization.patch`,确认基线仍匹配后再应用。候选依赖 `starlette>=1.6,<2` 的流式刷新及线程压缩行为。恢复后需重新构建前端并完成数值、异常、浏览器和真实转发连接的验收,才决定是否启用。 + +本次没有新增或上传运行环境。 diff --git a/docs/other/backups/2026-09-12-result-transfer/manifest.json b/docs/other/backups/2026-09-12-result-transfer/manifest.json new file mode 100644 index 0000000..2b6fd1c --- /dev/null +++ b/docs/other/backups/2026-09-12-result-transfer/manifest.json @@ -0,0 +1,52 @@ +{ + "status": "archived-not-enabled", + "date": "2026-09-12", + "basis": "Current accepted Jacobian implementation before this transfer experiment; not a rollback to git HEAD.", + "files": [ + { + "path": "app/main.py", + "beforeSha256": "ba8467bd855086e60e2f7b3f1d5cf61efbb8a698560fc5a44163c59c73982c96", + "candidateSha256": "89ccbf3b1aa949dcfca8cb28dd2ccfdb867c1e90ef3adf2a0142e8408a9e50a3" + }, + { + "path": "frontend/src/App.tsx", + "beforeSha256": "b43507ca9cde6e21bcfc8b057b1c15c495e02dc1c782b9c8a1f3bd3d7b29b883", + "candidateSha256": "8ef51d0483886dce459943e1e2cf27a37e205a03c3949ea227061642792ebec2" + }, + { + "path": "tests/manual/browser_stage_profile.mjs", + "beforeSha256": "127936b288a612b0f7bafd7ad906afa1c9edfc8254aee8cf97e7bd4cb1c058c2", + "candidateSha256": "fdad4ab82892cce90db1e662bc48dc7a7a62c7ebea176e6ec77097448279422e" + }, + { + "path": "requirements.txt", + "beforeSha256": "0580e0c33b80e6c740fffb5f61d43a26dede3ee209822a064429f35e9f68b696", + "candidateSha256": "13e99540643dbe6211e3619d3c15ceb1f56dcd61448f3cf930a3ace7df508171" + }, + { + "path": "tests/test_native_result_transport.py", + "beforeSha256": "9127978933adcb29e748a4d71e6ce66d2af949750b162f62299031161176c3af", + "candidateSha256": "c0f1a68e603d34f072eed3ca82efaff446970fc48a32196e0c3c50ad098738f6" + }, + { + "path": "app/result_compression.py", + "beforeSha256": null, + "candidateSha256": "82179874927292007020186af35bb9fb0314446d9fdf8951265f610899328451" + }, + { + "path": "frontend/tests/e2e/simulation-stream-terminal.spec.ts", + "beforeSha256": null, + "candidateSha256": "a903571ebcf995d43bd9ff2be86d40720ac24fd7c11925b07cbc66dae9cc4582" + }, + { + "path": "tests/test_simulation_result_compression.py", + "beforeSha256": null, + "candidateSha256": "77c99527ab3e5f2bf8b05f378b489cf175ff51b6b6c508040bcfda47422ab8e1" + }, + { + "path": "tests/manual/result_transfer_profile.py", + "beforeSha256": null, + "candidateSha256": "f362f079540a9787f307599d0bb79b8b984adb274750edffd4b5df0fd4b15d04" + } + ] +} diff --git a/docs/other/backups/2026-09-12-result-transfer/optimization.patch b/docs/other/backups/2026-09-12-result-transfer/optimization.patch new file mode 100644 index 0000000..2321a62 --- /dev/null +++ b/docs/other/backups/2026-09-12-result-transfer/optimization.patch @@ -0,0 +1,1246 @@ +--- a/app/main.py ++++ b/app/main.py +@@ -22,6 +22,7 @@ + from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse + from pydantic import BaseModel, ConfigDict, Field, ValidationError + ++from app.result_compression import SimulationResultCompressionMiddleware + from app.simulation.performance import performance_span, profile_phase, profile_run + from app.simulation.native_codegen.transport import NativeSeriesJson, serialize_result_parts + from app.simulation.config import SolverActivityTracker +@@ -58,6 +59,7 @@ + title="System Simulation ReactFlow App", + lifespan=_app_lifespan, + ) ++app.add_middleware(SimulationResultCompressionMiddleware) + FRONTEND_DIST_DIR = Path(__file__).resolve().parent.parent / "frontend" / "dist" + PROJECT_STORAGE_DIR = Path(__file__).parent / "data" / "reactflow-projects" + SYSTEM_XML_SCHEMA_VERSION = "3" +@@ -73,7 +75,7 @@ + "postprocessing": "正在整理采样结果", + "cancelled": "正在整理已终止仿真的部分结果", + "failed": "正在整理异常终止前的部分结果", +- "complete": "正在汇总仿真结果", ++ "complete": "计算已完成,正在接收仿真结果", + } + SIMULATION_STREAM_HEARTBEAT_SECONDS = 5.0 + SIMULATION_TASK_RETENTION_SECONDS = 600.0 +--- a/frontend/src/App.tsx ++++ b/frontend/src/App.tsx +@@ -11341,6 +11341,8 @@ + throw new SimulationStreamError("浏览器未收到仿真进度数据流"); + } + ++ const contentEncoding = response.headers.get("Content-Encoding")?.trim().toLowerCase(); ++ const requiresCompleteBody = Boolean(contentEncoding && contentEncoding !== "identity"); + const reader = response.body.getReader(); + let result: SimulationResult | null = null; + let activityWatchdog = createSimulationActivityWatchdog(); +@@ -11411,6 +11413,10 @@ + while (true) { + const { done, value } = await readSimulationStreamChunk(reader); + if (value) lineDecoder.write(value); ++ // Identity responses can finish at the terminal record. Encoded bodies ++ // must reach EOF so the browser validates gzip's trailer (or equivalent ++ // encoding integrity); decoded JSON can arrive before that validation. ++ if (result && !requiresCompleteBody) break; + if (done) { + lineDecoder.finish(); + break; +@@ -11419,7 +11425,9 @@ + } finally { + abortController.abort(); + try { +- await reader.cancel(); ++ // cancel() closes the reader locally before its underlying-source promise ++ // settles; remote cleanup must not delay publishing a completed result. ++ void reader.cancel().catch(() => {}); + } catch { + // The stream may already be closed or aborted. + } +--- a/tests/manual/browser_stage_profile.mjs ++++ b/tests/manual/browser_stage_profile.mjs +@@ -1,4 +1,5 @@ +-// Real production-page profiling. No route mocks, response cloning, or duplicate body parsing. ++// Real-page profiling; production by default, development only with explicit opt-in. ++// No route mocks, response cloning, or duplicate body parsing. + // All stage timestamps use the active document's performance.now(). A reload starts a new axis. + import { chromium } from '../../frontend/node_modules/playwright/index.mjs'; + import fs from 'node:fs/promises'; +@@ -6,11 +7,12 @@ + import assert from 'node:assert/strict'; + import { createHash } from 'node:crypto'; + +-const usage = `node tests/manual/browser_stage_profile.mjs --output DIR [--input tests/data/test-mql-8-corrected.json] [--url http://127.0.0.1:8011] [--runs 3 (0 for one smoke run)] [--mode both|profiled|control] [--deep] [--cpu-interval-us 1000] [--source-map-dir DIR] [--check] ++const usage = `node tests/manual/browser_stage_profile.mjs --output DIR [--input tests/data/test-mql-8-corrected.json] [--url http://127.0.0.1:8011] [--runs 3 (0 for one smoke run)] [--mode both|profiled|control] [--deep] [--cpu-interval-us 1000] [--source-map-dir DIR] [--allow-development] [--check] + Offline only: node tests/manual/browser_stage_profile.mjs --summarize-cpu-only EXISTING_DIRECTORY [--source-map-dir DIR] [--check] + Each mode runs one warmup followed by RUNS measured runs, sequentially. --check validates inputs without launching a browser. + Optional --deep (alias --cpu-profile) records renderer-main-thread .cpuprofile diagnostics separately from ordinary endpoint timing. + --source-map-dir accepts an offline hidden-source-map build only when its generated JS bytes exactly match served assets. ++--allow-development permits Vite development assets (for example forwarded port 5173); reports are labelled development and must not be mixed with production timings. + Run with the repository Node 24 and Chromium runtime library environment. No application code is modified.`; + const args = process.argv.slice(2); + if (args.includes('--help')) { console.log(usage); process.exit(0); } +@@ -18,6 +20,7 @@ + for (let i = 0; i < args.length; i++) { + if (['--deep', '--cpu-profile'].includes(args[i])) { options.deep = true; continue; } + if (args[i] === '--check') { options.check = true; continue; } ++ if (args[i] === '--allow-development') { options.allowDevelopment = true; continue; } + const cliKey = args[i].replace(/^--/, ''); + const key = ({ 'cpu-interval-us': 'cpuIntervalUs', 'source-map-dir': 'sourceMapDir', 'summarize-cpu-only': 'summarizeCpuOnly' })[cliKey] ?? cliKey; + if (!['input', 'output', 'url', 'runs', 'mode', 'cpuIntervalUs', 'sourceMapDir', 'summarizeCpuOnly'].includes(key) || !args[i + 1]) throw new Error(usage); +@@ -63,6 +66,8 @@ + Object.assign(data, { marks: {}, requests: [], reads: [], parses: [], decodes: [], transactions: [], workers: [], runFailure: undefined, + downloads: [], longTasks: [], streamActive: false, activeRun: true }); + performance.clearMarks(); ++ // Resource entries for this run must not be displaced by Vite startup imports. ++ performance.clearResourceTimings(); + }; + data.armClick = (name, selector) => { + const listener = event => { +@@ -83,7 +88,10 @@ + const failure = document.querySelector('.simulation-console-dock-progress.error, .simulation-console-progress.error'); + if (failure) { data.runFailure = failure.textContent; mark('runFailure'); observer.disconnect(); return; } + const button = document.querySelector('button[aria-label="运行仿真"]'); +- observedBusy ||= Boolean(button?.disabled || document.querySelector('.simulation-console-dock-progress.running, .simulation-console-progress.running')); ++ const runningProgress = document.querySelector('.simulation-console-dock-progress.running, .simulation-console-progress.running'); ++ observedBusy ||= Boolean(button?.disabled || runningProgress); ++ const progressText = runningProgress?.textContent ?? ''; ++ if (progressText.includes('正在汇总仿真结果') || progressText.includes('计算已完成,正在接收仿真结果')) mark('completeProgressDom'); + const success = document.querySelector('.simulation-console-dock-progress.success, .simulation-console-progress.success'); + if (observedBusy && button && !button.disabled && success?.textContent.includes('仿真完成')) { + mark('resultReadyDom'); +@@ -151,7 +159,8 @@ + .filter(e => /simulate-stream|simulation-results\/csv/.test(e.name)) + .map(e => ({ name: e.name, startTime: e.startTime, requestStart: e.requestStart, responseStart: e.responseStart, + responseEnd: e.responseEnd, duration: e.duration, transferSize: e.transferSize, +- encodedBodySize: e.encodedBodySize, decodedBodySize: e.decodedBodySize })) }); ++ encodedBodySize: e.encodedBodySize, decodedBodySize: e.decodedBodySize, ++ nextHopProtocol: e.nextHopProtocol, deliveryType: e.deliveryType ?? null })) }); + if (location.hash === '#/results' && sessionStorage.getItem(storageKey)) { + data.watchDom('restoredResults', '.results-shell .results-system-panel'); + } +@@ -169,6 +178,8 @@ + if (kind === 'simulation') { mark('fetchStart', row.fetchStart); data.streamActive = true; } + return Reflect.apply(originalFetch, this, args).then(response => { + row.headers = performance.now(); row.status = response.status; ++ row.contentEncoding = response.headers.get('content-encoding'); ++ row.contentLength = response.headers.get('content-length'); + if (kind === 'simulation') mark('headers', row.headers); + if (response.body) bodies.set(response.body, row); + return response; +@@ -200,9 +211,14 @@ + const parsed = Reflect.apply(nativeParse, this, args); + const end = performance.now(); + if (parsed && ['progress', 'result', 'error'].includes(parsed.event)) { +- data.parses.push({ event: parsed.event, phase: parsed.phase, start, end, characters: typeof args[0] === 'string' ? args[0].length : null }); ++ data.parses.push({ event: parsed.event, phase: parsed.phase, heartbeat: Boolean(parsed.heartbeat), start, end, characters: typeof args[0] === 'string' ? args[0].length : null }); ++ if (parsed.event === 'progress' && parsed.phase === 'complete' && !parsed.heartbeat) { ++ mark('completeProgressParsed', end); ++ } + if (parsed.event === 'result') { + mark('resultParseStart', start); mark('resultParseEnd', end); ++ // A later suffix/newline/read may otherwise overwrite lastChunk. ++ if (data.marks.lastChunk !== undefined) mark('resultBoundaryReadAtParseStart', data.marks.lastChunk); + // This microtask is only a checkpoint after the current consumer continuation, + // not a claim that all EOF/finally/publish/React work has completed. + queueMicrotask(() => mark('resultConsumerMicrotaskCheckpoint')); +@@ -676,16 +692,17 @@ + + if (options.check) { + checkCpuSummaryContract(); ++ checkFinalizationMeasurementContract(); + if (options.summarizeCpuOnly) { + const manifest = JSON.parse(await fs.readFile(path.join(options.summarizeCpuOnly, 'summary.json'))); + assert.ok(manifest.cpuDiagnostics?.profiles?.length); + console.log(JSON.stringify({ directory: options.summarizeCpuOnly, profiles: manifest.cpuDiagnostics.profiles.length, +- cpuClassificationContractPassed: true, browserLaunched: false })); ++ cpuClassificationContractPassed: true, finalizationMeasurementContractPassed: true, browserLaunched: false })); + } else { + console.log(JSON.stringify({ input: options.input, inputSha256: sha(inputText), nodes: project.nodes.length, + edges: project.edges.length, curveNodeId, mode: options.mode, deep: Boolean(options.deep), cpuIntervalUs: Number(options.cpuIntervalUs), +- sourceMapDir: options.sourceMapDir ?? null, warmupsPerMode: 1, measuredRunsPerMode: Number(options.runs), +- cpuClassificationContractPassed: true, browserLaunched: false })); ++ sourceMapDir: options.sourceMapDir ?? null, allowDevelopment: Boolean(options.allowDevelopment), warmupsPerMode: 1, measuredRunsPerMode: Number(options.runs), ++ cpuClassificationContractPassed: true, finalizationMeasurementContractPassed: true, browserLaunched: false })); + } + process.exit(0); + } +@@ -701,6 +718,9 @@ + headersAndReads: 'fetch resolution and consumer read delivery. Outstanding-read intervals include backend production, transport and browser scheduling; not pure network time.', + unobservedCpu: 'NDJSON fragment scanning/join/trim and React handler CPU are not isolated. Their residual intervals can also include scheduling and cannot be attributed wholesale to parsing, transport or drawing.', + jsonParse: 'Only the original synchronous JSON.parse call, called once per application parse. No duplicate body read, decode, scan or parse.', ++ finalization: 'completeProgressDom is the first target text in the active running-progress DOM, observed in control and profiled modes; not a paint timestamp. completeProgressParsed is the first non-heartbeat complete progress JSON parse end, profiled only. The two origins are independent and may be absent or differently ordered. Endpoints include result parse/EOF/ready; null and negative deltas are retained. If the application finishes on the result event and cancels before EOF, EOF stays absent rather than being invented.', ++ responseEncoding: 'Content-Encoding/Content-Length are captured from real response headers without reading the body. Resource Timing encodedBodySize is compressed HTTP body size; decodedBodySize and reader byte counts are after content decoding. transferSize also includes browser-reported response-header overhead; none is a precise TLS/port-forward wire-byte measurement. Zero sizes may mean caching or unavailable/incomplete timing and are not replaced by decoded bytes. An early stream cancel may leave Resource Timing incomplete or absent.', ++ frontendEnvironment: 'Production assets are required by default. --allow-development permits observed Vite assets and labels the group development; development costs must not be combined with production groups. Asset hashes cover document tags and observed workers, not the entire Vite module graph.', + resultReady: 'First DOM observation of successful completion plus an enabled run button after busy state. React state/handler boundaries are not directly instrumented.', + indexedDb: 'Profiled: session pointer publication immediately after all save transactions commit. Control: pointer polling, up to 16 ms plus scheduling delay. Transaction windows also include asynchronous waiting and may include old-cache cleanup.', + render: 'First visible DOM, then two requestAnimationFrame callbacks (paint opportunity, not GPU completion); stable means scoped DOM quiet for 120 ms followed by two frames.', +@@ -716,6 +736,7 @@ + const errors = []; + const browser = await chromium.launch({ headless: true }); + const evidence = { input: path.resolve(options.input), inputSha256: sha(inputText), baseURL: options.url, ++ allowDevelopment: Boolean(options.allowDevelopment), frontendEnvironment: null, + browser: browser.version(), node: process.version, deep: Boolean(options.deep), cpuIntervalUs: options.deep ? Number(options.cpuIntervalUs) : null, initialNavigation: {}, scriptSha256: sha(await fs.readFile(new URL(import.meta.url))), definitions, rows, errors, servedAssets: [] }; + const writeSummary = () => fs.writeFile(path.join(options.output, 'summary.json'), JSON.stringify(evidence, null, 2)); + const waitMark = async (page, name) => { +@@ -728,16 +749,40 @@ + const watch = (page, name, selector) => page.evaluate(({ name, selector }) => window.__stageProfile.watchDom(name, selector), { name, selector }); + const clickTab = async (page, name) => { await page.getByRole('tab', { name }).click(); }; + const resultDigest = result => sha(JSON.stringify(result)); +-const delta = (m, a, b) => m[a] === undefined || m[b] === undefined ? null : m[b] - m[a]; ++function delta(m, a, b) { return m[a] === undefined || m[b] === undefined ? null : m[b] - m[a]; } + function metrics(trace) { + const m = trace.marks; + const csvAnchor = trace.downloads.find(d => d.name.endsWith('.csv'))?.anchorClick; + const resultAnchor = trace.downloads.find(d => d.name.endsWith('.simresult'))?.anchorClick; + const csvRequest = trace.requests.find(r => r.kind === 'csv'); ++ const simulationRequest = trace.requests.find(r => r.kind === 'simulation'); ++ const simulationResponse = trace.simulationResponse ?? simulationRequest; ++ const resources = (trace.resources ?? []).filter(r => r.name.includes('/api/system-xml/simulate-stream')); ++ const resource = resources.length === 1 ? resources[0] : null; ++ const contentLength = simulationResponse?.contentLength; ++ const finalization = {}; ++ for (const [label, start] of [['completeDom', 'completeProgressDom'], ['completeParsed', 'completeProgressParsed']]) { ++ for (const [endpoint, end] of [['ReadyDom', 'resultReadyDom'], ['ReadyPaintOpportunity', 'resultReadyPaintOpportunity'], ++ ['ParseStart', 'resultParseStart'], ['ParseEnd', 'resultParseEnd'], ['Eof', 'streamEof'], ++ ['ResultBoundaryRead', 'resultBoundaryReadAtParseStart']]) { ++ finalization[`${label}To${endpoint}Ms`] = delta(m, start, end); ++ } ++ } + const worker = trace.workers?.[0]; + const workerStart = worker?.posts.find(p => p.type === 'start'); + const workerFinish = worker?.posts.find(p => p.type === 'finish'); + return { ++ ...finalization, ++ completeParsedToDomMs: delta(m, 'completeProgressParsed', 'completeProgressDom'), ++ resultBoundaryReadToParseStartMs: delta(m, 'resultBoundaryReadAtParseStart', 'resultParseStart'), ++ responseContentEncoding: simulationResponse?.contentEncoding ?? null, ++ responseContentLengthBytes: typeof contentLength === 'string' && /^\d+$/.test(contentLength) ? Number(contentLength) : null, ++ responseTransferEncoding: simulationResponse?.transferEncoding ?? null, ++ resourceTimingSimulationEntries: resources.length, ++ resourceTransferBytes: resource?.transferSize ?? null, ++ resourceEncodedBodyBytes: resource?.encodedBodySize ?? null, ++ resourceDecodedBodyBytes: resource?.decodedBodySize ?? null, ++ resourceDecodedToEncodedRatio: resource?.encodedBodySize > 0 && resource?.decodedBodySize > 0 ? resource.decodedBodySize / resource.encodedBodySize : null, + importToDomMs: delta(m, 'importChange', 'importReadyDom'), + importToPaintOpportunityMs: delta(m, 'importChange', 'importReadyPaintOpportunity'), + clickToFetchMs: delta(m, 'runClick', 'fetchStart'), fetchToHeadersMs: delta(m, 'fetchStart', 'headers'), +@@ -773,6 +818,49 @@ + synchronousStreamDecodeMs: trace.profiled ? trace.decodes.reduce((n, r) => n + r.end - r.start, 0) : null, + }; + } ++function classifyFrontendEnvironment(assetUrls, allowDevelopment) { ++ const environment = assetUrls.some(url => /@vite\/client|@react-refresh|\/src\//.test(url)) ? 'development' : 'production'; ++ if (environment === 'development' && !allowDevelopment) { ++ throw new Error('Expected a production build, found Vite development assets. Use --allow-development for a separately labelled development measurement.'); ++ } ++ return environment; ++} ++ ++function checkFinalizationMeasurementContract() { ++ const trace = { profiled: true, marks: { completeProgressParsed: 8, completeProgressDom: 10, ++ resultBoundaryReadAtParseStart: 35, resultParseStart: 40, resultParseEnd: 100, resultReadyDom: 120 }, ++ requests: [], reads: [], parses: [], decodes: [], workers: [], downloads: [], ++ simulationResponse: { contentEncoding: 'gzip', contentLength: '100' }, ++ resources: [{ name: 'http://localhost/api/system-xml/simulate-stream', transferSize: 120, encodedBodySize: 100, decodedBodySize: 1000 }] }; ++ const row = metrics(trace); ++ assert.equal(row.completeDomToReadyDomMs, 110); ++ assert.equal(row.completeParsedToReadyDomMs, 112); ++ assert.equal(row.completeParsedToDomMs, 2); ++ assert.equal(row.completeParsedToParseStartMs, 32); ++ assert.equal(row.completeParsedToParseEndMs, 92); ++ assert.equal(row.completeParsedToEofMs, null, 'Early result publication must not invent EOF.'); ++ assert.equal(row.resultBoundaryReadToParseStartMs, 5); ++ assert.equal(row.responseContentEncoding, 'gzip'); ++ assert.equal(row.responseContentLengthBytes, 100); ++ assert.equal(row.resourceEncodedBodyBytes, 100); ++ assert.equal(row.resourceDecodedBodyBytes, 1000); ++ assert.equal(row.resourceDecodedToEncodedRatio, 10); ++ assert.equal(metrics({ ...trace, marks: { ...trace.marks, completeProgressDom: 60 } }).completeDomToParseStartMs, -20); ++ const control = metrics({ ...trace, profiled: false, marks: { completeProgressDom: 10, resultReadyDom: 120 }, resources: [] }); ++ assert.equal(control.completeDomToReadyDomMs, 110); ++ assert.equal(control.completeParsedToReadyDomMs, null); ++ assert.equal(control.completeDomToParseStartMs, null); ++ assert.equal(control.streamBytes, null); ++ assert.equal(control.resourceEncodedBodyBytes, null); ++ const zero = metrics({ ...trace, resources: [{ ...trace.resources[0], transferSize: 0, encodedBodySize: 0 }] }); ++ assert.equal(zero.resourceEncodedBodyBytes, 0); ++ assert.equal(zero.resourceDecodedToEncodedRatio, null, 'Missing wire-size evidence must not be replaced by decoded size.'); ++ assert.equal(metrics({ ...trace, resources: [...trace.resources, ...trace.resources] }).resourceEncodedBodyBytes, null); ++ assert.equal(classifyFrontendEnvironment(['http://localhost/assets/index-abc.js'], false), 'production'); ++ assert.equal(classifyFrontendEnvironment(['http://localhost/@vite/client'], true), 'development'); ++ assert.throws(() => classifyFrontendEnvironment(['http://localhost/src/main.tsx'], false), /--allow-development/); ++} ++ + try { + for (const mode of options.mode === 'both' ? ['control', 'profiled'] : [options.mode]) { + const context = await browser.newContext({ viewport: { width: 1600, height: 1000 }, acceptDownloads: true }); +@@ -781,12 +869,25 @@ + const cpuRecorder = options.deep ? await createCpuRecorder(context, page, Number(options.cpuIntervalUs)) : null; + page.setDefaultTimeout(30000); + const simulationRequests = []; ++ const requestRecords = new WeakMap(); + const workerUrls = new Set(); + page.on('worker', worker => workerUrls.add(worker.url())); + page.on('request', request => { + if (request.url().includes('/api/system-xml/simulate-stream')) { +- simulationRequests.push({ url: request.url(), simulationId: request.headers()['x-simulation-id'] ?? null }); ++ const row = { url: request.url(), simulationId: request.headers()['x-simulation-id'] ?? null }; ++ simulationRequests.push(row); ++ requestRecords.set(request, row); + } ++ }); ++ // Passive header metadata in both modes; never response.body/text/json. ++ page.on('response', response => { ++ const row = requestRecords.get(response.request()); ++ if (!row) return; ++ const headers = response.headers(); ++ row.response = { status: response.status(), contentEncoding: headers['content-encoding'] ?? null, ++ contentLength: headers['content-length'] ?? null, transferEncoding: headers['transfer-encoding'] ?? null, ++ contentType: headers['content-type'] ?? null, vary: headers.vary ?? null, ++ fromServiceWorker: response.fromServiceWorker(), source: 'playwright-response-headers' }; + }); + page.on('pageerror', error => errors.push({ mode, error: String(error) })); + page.on('dialog', dialog => { errors.push({ mode, dialog: dialog.message() }); void dialog.dismiss(); }); +@@ -797,16 +898,18 @@ + appControlObservedAt: performance.now(), navigation: performance.getEntriesByType('navigation').map(e => e.toJSON()) })); + const assetUrls = await page.evaluate(() => [...document.querySelectorAll('script[src],link[rel="stylesheet"][href]')] + .map(e => e.src || e.href)); +- if (assetUrls.some(url => /@vite\/client|\/src\//.test(url))) throw new Error('Expected a production build, found Vite development assets.'); ++ const frontendEnvironment = classifyFrontendEnvironment(assetUrls, Boolean(options.allowDevelopment)); ++ if (evidence.frontendEnvironment !== null) assert.equal(evidence.frontendEnvironment, frontendEnvironment, 'Frontend environment changed between modes.'); ++ evidence.frontendEnvironment = frontendEnvironment; + for (const url of assetUrls) { + const response = await context.request.get(url); + assert.ok(response.ok(), `Asset HTTP ${response.status()}: ${url}`); + const bytes = await response.body(); + const previous = evidence.servedAssets.find(asset => asset.url === url); +- if (previous) assert.equal(previous.sha256, sha(bytes), 'Production asset changed between modes.'); ++ if (previous) assert.equal(previous.sha256, sha(bytes), 'Frontend asset changed between modes.'); + else evidence.servedAssets.push({ url, sha256: sha(bytes), bytes: bytes.length }); + } +- assert.ok(evidence.servedAssets.length, 'No production assets found.'); ++ assert.ok(evidence.servedAssets.length, 'No frontend assets found.'); + evidence.buildAssetSetSha256 = sha(JSON.stringify(evidence.servedAssets + .map(({ url, ...asset }) => ({ path: new URL(url).pathname, ...asset })) + .sort((a, b) => a.path.localeCompare(b.path)))); +@@ -871,6 +974,11 @@ + await saveDownload('下载结果 CSV', 'csvClick', 'csvDownloadSaved', 'result.csv'); + await saveDownload('下载结果文件', 'resultFileClick', 'resultFileDownloadSaved', 'result.simresult'); + const trace = await page.evaluate(() => window.__stageProfile.snapshot()); ++ const requests = simulationRequests.slice(requestOffset); ++ assert.equal(requests.length, 1, 'Expected exactly one real simulation request.'); ++ assert.ok(requests[0].simulationId, 'Missing X-Simulation-Id correlation key.'); ++ assert.ok(requests[0].response, 'Missing simulation response-header evidence.'); ++ trace.simulationResponse = { simulationId: requests[0].simulationId, ...requests[0].response }; + if (cpuRecorder) { + const capture = await cpuRecorder.stop(); + const file = path.join(runDir, 'interaction.cpuprofile'); +@@ -908,10 +1016,7 @@ + const restoreTrace = await page.evaluate(() => ({ ...window.__stageProfile.snapshot(), + navigation: performance.getEntriesByType('navigation').map(e => e.toJSON()) })); + await fs.writeFile(path.join(runDir, 'restore-trace.json'), JSON.stringify(restoreTrace, null, 2)); +- const requests = simulationRequests.slice(requestOffset); +- assert.equal(requests.length, 1, 'Expected exactly one real simulation request.'); +- assert.ok(requests[0].simulationId, 'Missing X-Simulation-Id correlation key.'); +- const row = { mode, deep: Boolean(options.deep), run, warmup: run === 0, simulationId: requests[0].simulationId, timeOrigin: trace.timeOrigin, ...metrics(trace), ++ const row = { mode, frontendEnvironment, deep: Boolean(options.deep), run, warmup: run === 0, simulationId: requests[0].simulationId, timeOrigin: trace.timeOrigin, ...metrics(trace), + restoreNavigationToDomMs: restoreTrace.marks.restoredResultsDom, + restoreNavigationToPaintOpportunityMs: restoreTrace.marks.restoredResultsPaintOpportunity, + restoreNavigationToDomStableMs: restoreTrace.marks.restoredResultsStable, +--- a/requirements.txt ++++ b/requirements.txt +@@ -4,3 +4,6 @@ + lxml>=5,<7 + pydantic>=2,<3 + uvicorn[standard] ++ ++# Streaming gzip must flush progress and offload large compression blocks. ++starlette>=1.6,<2 +--- a/tests/test_native_result_transport.py ++++ b/tests/test_native_result_transport.py +@@ -1,6 +1,9 @@ + """The HTTP fast path must preserve real native values and task semantics.""" + from dataclasses import replace ++import gzip + import json ++import struct ++import zlib + from pathlib import Path + import tempfile + from types import SimpleNamespace +@@ -23,7 +26,7 @@ + """Exercise real routing/response bodies without an optional HTTP client dependency.""" + def __init__(self, application): self.application = application + def post(self, path, *, content=b'', headers=None): return self.request('POST', path, content, headers) +- def get(self, path): return self.request('GET', path, b'', None) ++ def get(self, path, *, headers=None): return self.request('GET', path, b'', headers) + def request(self, method, path, content, headers): + async def run(): + messages = [] +@@ -37,7 +40,11 @@ + await self.application(scope,receive,send) + status = next(m['status'] for m in messages if m['type']=='http.response.start') + body = b''.join(m.get('body',b'') for m in messages if m['type']=='http.response.body') +- return SimpleNamespace(status_code=status,content=body,json=lambda:json.loads(body)) ++ response_headers = {key.decode('latin-1'): value.decode('latin-1') ++ for key, value in next(m['headers'] for m in messages if m['type']=='http.response.start')} ++ chunks = tuple(m.get('body', b'') for m in messages if m['type']=='http.response.body') ++ return SimpleNamespace(status_code=status, content=body, headers=response_headers, ++ chunks=chunks, json=lambda:json.loads(body)) + return asyncio.run(run()) + + +@@ -95,6 +102,71 @@ + for key in ('series', 'final', 'variables', 'model', 'simulation'): + self.assertEqual(synchronous[key], result[key]) + ++ def test_real_http_gzip_stream_and_retained_task_preserve_identity_results(self): ++ client = AsgiClient(app) ++ ident = 'transport-gzip-'+uuid4().hex ++ def load(data): ++ return json.loads(data, parse_int=lambda token: -0.0 if token == '-0' else int(token)) ++ def decode(response, *, progress=False): ++ self.assertEqual(response.status_code, 200) ++ self.assertEqual(response.headers['content-encoding'], 'gzip') ++ self.assertIn('accept-encoding', response.headers['vary'].lower()) ++ decoder = zlib.decompressobj(zlib.MAX_WBITS + 16) ++ parts = [] ++ first_progress = False ++ for index, chunk in enumerate(response.chunks): ++ part = decoder.decompress(chunk) ++ parts.append(part) ++ if progress and not first_progress and b'\n' in part: ++ self.assertEqual(load(part.split(b'\n', 1)[0])['event'], 'progress') ++ self.assertLess(index, len(response.chunks)-1) ++ self.assertFalse(decoder.eof, 'Progress must precede gzip stream completion') ++ first_progress = True ++ parts.append(decoder.flush()) ++ decoded = b''.join(parts) ++ self.assertTrue(decoder.eof, 'The real HTTP response needs its complete gzip trailer') ++ self.assertEqual(decoder.unused_data, b'') ++ self.assertEqual(decoder.unconsumed_tail, b'') ++ self.assertEqual(gzip.decompress(response.content), decoded) ++ if progress: ++ self.assertTrue(first_progress) ++ return decoded ++ compressed = client.post('/api/system-xml/simulate-stream', content=self.xml, ++ headers={'X-Simulation-Id': ident, 'Accept-Encoding': 'gzip'}) ++ events = [load(line) for line in decode(compressed, progress=True).splitlines()] ++ results = [event['result'] for event in events if event['event'] == 'result'] ++ self.assertEqual(len(results), 1) ++ result = results[0] ++ self.assertTrue(result['success']) ++ self.assertEqual(result['simulatedUntil'], .1) ++ identity = client.post('/api/system-xml/simulate-stream', content=self.xml, ++ headers={'X-Simulation-Id': ident+'-identity', 'Accept-Encoding': 'identity'}) ++ self.assertNotIn('content-encoding', identity.headers) ++ identity_result = next(event['result'] for event in map(load, identity.content.splitlines()) ++ if event['event'] == 'result') ++ for key in ('success', 'status', 'partial', 'simulatedUntil', 'requestedStopTime', ++ 'variables', 'model', 'simulation'): ++ self.assertEqual(result[key], identity_result[key], key) ++ self.assertEqual(result['diagnostics']['integration']['totals'], ++ identity_result['diagnostics']['integration']['totals']) ++ # Compare numeric bits, including signed zero, independently of gzip's ++ # byte oracle; timings legitimately vary between these two actual runs. ++ self.assertEqual(result['series'].keys(), identity_result['series'].keys()) ++ for key, values in result['series'].items(): ++ expected = identity_result['series'][key] ++ self.assertEqual(len(values), len(expected), key) ++ self.assertEqual(b''.join(struct.pack('!d', value) for value in values), ++ b''.join(struct.pack('!d', value) for value in expected), key) ++ self.assertEqual(result['final'].keys(), identity_result['final'].keys()) ++ for key, value in result['final'].items(): ++ self.assertEqual(struct.pack('!d', value), struct.pack('!d', identity_result['final'][key]), key) ++ retained = client.get('/api/system-xml/simulations/'+ident, headers={'Accept-Encoding': 'gzip'}) ++ retained_bytes = decode(retained) ++ retained_identity = client.get('/api/system-xml/simulations/'+ident, ++ headers={'Accept-Encoding': 'identity'}) ++ self.assertEqual(retained_bytes, retained_identity.content) ++ self.assertEqual(load(retained_bytes)['result'], result) ++ + def test_cancelled_raw_stream_keeps_partial_result_and_public_status(self): + for reason, status in [('user', 'stopped'), ('stalled', 'stalled')]: + task = _register_simulation_task('transport-'+uuid4().hex) +--- /dev/null ++++ b/app/result_compression.py +@@ -0,0 +1,84 @@ ++"""Lossless, promptly flushed compression for large simulation responses. ++ ++Starlette >= 1.6 flushes each streaming body and moves large compression ++blocks off the event loop. Bound its input so transmission can overlap the ++encoding of the next block, including when the native series is one big body. ++""" ++ ++from __future__ import annotations ++ ++import re ++ ++from starlette.datastructures import Headers ++from starlette.middleware.gzip import GZipResponder, IdentityResponder ++from starlette.types import ASGIApp, Message, Receive, Scope, Send ++ ++ ++RESULT_COMPRESSION_CHUNK_BYTES = 256 * 1024 ++_QUALITY = re.compile(r"(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)\Z") ++ ++ ++def _accepts_gzip(headers: Headers) -> bool: ++ qualities: dict[str, float] = {} ++ for header in headers.getlist("accept-encoding"): ++ for item in header.split(","): ++ coding, *parameters = item.lower().strip().split(";") ++ coding = coding.strip() ++ quality = 1.0 ++ for parameter in parameters: ++ key, separator, value = parameter.strip().partition("=") ++ if key.strip() == "q": ++ value = value.strip() ++ quality = min(quality, float(value) if separator and _QUALITY.fullmatch(value) else 0.0) ++ # A repeated prohibition takes precedence over another entry. ++ qualities[coding] = min(qualities.get(coding, 1.0), quality) ++ gzip_quality = qualities.get("gzip", qualities.get("*", 0.0)) ++ return gzip_quality > 0.0 and gzip_quality >= qualities.get("identity", 0.0) ++ ++ ++def _is_result_request(scope: Scope) -> bool: ++ path = scope.get("path", "") ++ method = scope.get("method", "") ++ if method == "POST": ++ return path in {"/api/system-xml/simulate", "/api/system-xml/simulate-stream"} ++ prefix = "/api/system-xml/simulations/" ++ if method == "GET" and path.startswith(prefix): ++ identifier = path[len(prefix):] ++ return bool(identifier) and "/" not in identifier ++ return False ++ ++ ++class SimulationResultCompressionMiddleware: ++ def __init__(self, app: ASGIApp) -> None: ++ self.app = app ++ ++ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: ++ if scope["type"] != "http" or not _is_result_request(scope): ++ await self.app(scope, receive, send) ++ return ++ if not _accepts_gzip(Headers(scope=scope)): ++ await IdentityResponder(self.app, minimum_size=500)(scope, receive, send) ++ return ++ ++ async def bounded_app(scope: Scope, receive: Receive, send: Send) -> None: ++ can_split = True ++ ++ async def send_bounded(message: Message) -> None: ++ nonlocal can_split ++ if message["type"] == "http.response.start": ++ can_split = message["status"] != 206 and "content-encoding" not in Headers(raw=message["headers"]) ++ body = message.get("body", b"") ++ if message["type"] == "http.response.body" and can_split and len(body) > RESULT_COMPRESSION_CHUNK_BYTES: ++ for offset in range(0, len(body), RESULT_COMPRESSION_CHUNK_BYTES): ++ end = offset + RESULT_COMPRESSION_CHUNK_BYTES ++ await send({ ++ **message, ++ "body": body[offset:end], ++ "more_body": end < len(body) or message.get("more_body", False), ++ }) ++ else: ++ await send(message) ++ ++ await self.app(scope, receive, send_bounded) ++ ++ await GZipResponder(bounded_app, minimum_size=500, compresslevel=1)(scope, receive, send) +--- /dev/null ++++ b/frontend/tests/e2e/simulation-stream-terminal.spec.ts +@@ -0,0 +1,183 @@ ++import { expect, test, type Page } from "@playwright/test"; ++import { expandSimulationConsole, prepareApp, resultSnapshot, wideProject } from "./fixtures"; ++ ++async function prepareOpenStream(page: Page, cancelBehavior: "pending" | "reject" = "pending", encoding?: string) { ++ await prepareApp(page); ++ await page.addInitScript(({ cancelBehavior, encoding }) => { ++ const nativeFetch = window.fetch.bind(window); ++ const probe = { ++ body: null as ReadableStream | null, ++ signal: null as AbortSignal | null, ++ enqueue: (_text: string) => {}, ++ close: () => {}, ++ fail: (_message: string) => {}, ++ closedByServer: false, ++ cancellations: 0, ++ unhandled: [] as string[], ++ }; ++ (window as any).__terminalStream = probe; ++ window.addEventListener("unhandledrejection", (event) => probe.unhandled.push(String(event.reason))); ++ window.fetch = async (input, init) => { ++ if (!String(input).includes("/api/system-xml/simulate-stream")) return nativeFetch(input, init); ++ const encoder = new TextEncoder(); ++ probe.signal = init?.signal ?? null; ++ probe.body = new ReadableStream({ ++ start(controller) { ++ probe.enqueue = (text) => controller.enqueue(encoder.encode(text)); ++ probe.close = () => { probe.closedByServer = true; controller.close(); }; ++ probe.fail = (message) => controller.error(new TypeError(message)); ++ }, ++ cancel() { ++ probe.cancellations++; ++ // The response does not reach EOF; underlying cleanup may also never ++ // settle or reject. Neither is allowed to block a terminal result. ++ return cancelBehavior === "pending" ++ ? new Promise(() => {}) ++ : Promise.reject(new Error("test transport cancellation rejection")); ++ }, ++ }); ++ const headers = new Headers({ "Content-Type": "application/x-ndjson" }); ++ if (encoding) headers.set("Content-Encoding", encoding); ++ return new Response(probe.body, { headers }); ++ }; ++ }, { cancelBehavior, encoding }); ++ await page.goto("/"); ++ await page.locator('input[type="file"]').setInputFiles({ ++ name: "terminal-stream.json", mimeType: "application/json", ++ buffer: Buffer.from(JSON.stringify({ ...wideProject, edges: [...wideProject.edges, { ++ id: "close-test-loop", source: "generic_sensor_4", target: "generic_sensor_1", ++ sourceHandle: "port_b", targetHandle: "port_a", data: { isContactEdge: false }, ++ }] })), ++ }); ++ await expect(page.getByRole("textbox", { name: "工程", exact: true })).toHaveValue("terminal-stream"); ++ await page.getByRole("button", { name: "运行仿真", exact: true }).click(); ++ await expect.poll(() => page.evaluate(() => Boolean((window as any).__terminalStream.body))).toBe(true); ++ await expandSimulationConsole(page); ++} ++ ++async function expectReleasedStream(page: Page) { ++ expect(await page.evaluate(() => { ++ const probe = (window as any).__terminalStream; ++ return { aborted: probe.signal.aborted, locked: probe.body.locked, ++ cancellations: probe.cancellations, closedByServer: probe.closedByServer, unhandled: probe.unhandled }; ++ })).toEqual({ aborted: true, locked: false, cancellations: 1, closedByServer: false, unhandled: [] }); ++} ++ ++for (const status of ["completed", "stopped"] as const) { ++ test(`${status} result is published before EOF and transport cleanup, preserving every value`, async ({ page }) => { ++ const errors: string[] = []; ++ page.on("pageerror", (error) => errors.push(error.message)); ++ await prepareOpenStream(page, status === "completed" ? "pending" : "reject", status === "stopped" ? "identity" : undefined); ++ const result = { ++ ...resultSnapshot.result, status, success: status === "completed", partial: status === "stopped", ++ simulatedUntil: status === "completed" ? 10 : 4, ++ series: { time: status === "completed" ? [0, 5, 10] : [0, 2, 4], ++ "generic_sensor_1.value": [Number.MIN_VALUE, -1.2345678901234567, Number.MAX_VALUE] }, ++ final: { "generic_sensor_1.value": Number.MAX_VALUE }, ++ }; ++ const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true }); ++ const run = page.getByRole("button", { name: "运行仿真", exact: true }); ++ // The complete JSON text alone is not yet a complete NDJSON record. ++ await page.evaluate((result) => { ++ (window as any).__terminalStream.enqueue(JSON.stringify({ event: "progress", phase: "complete", ++ progress: 100, simulatedTime: result.simulatedUntil, totalTime: 10, message: "正在汇总仿真结果" }) + "\n" + ++ JSON.stringify({ event: "result", result })); ++ }, result); ++ await expect(panel).toContainText("正在汇总仿真结果"); ++ await expect(run).toBeDisabled(); ++ await page.evaluate(() => (window as any).__terminalStream.enqueue("\n")); ++ // This deadline is below the 30-second idle timeout; EOF is never sent. ++ await expect(run).toBeEnabled({ timeout: 3000 }); ++ await expect(panel).toContainText(status === "completed" ? "仿真完成,已生成新的结果" : "已手动终止"); ++ await expect(panel).not.toContainText("仿真失败"); ++ await expectReleasedStream(page); ++ await page.getByRole("tab", { name: /^结果/ }).click(); ++ const pendingDownload = page.waitForEvent("download"); ++ await page.getByRole("button", { name: "下载结果文件", exact: true }).click(); ++ const stream = await (await pendingDownload).createReadStream(); ++ expect(stream).not.toBeNull(); ++ const chunks: Buffer[] = []; ++ for await (const chunk of stream!) chunks.push(Buffer.from(chunk)); ++ const exported = JSON.parse(Buffer.concat(chunks).toString("utf8")); ++ expect(exported.snapshot.result).toEqual(result); ++ expect(errors).toEqual([]); ++ await expectReleasedStream(page); ++ }); ++} ++ ++test("an error before any terminal result releases an open stream without waiting for cleanup", async ({ page }) => { ++ const errors: string[] = []; ++ page.on("pageerror", (error) => errors.push(error.message)); ++ await prepareOpenStream(page); ++ await page.evaluate(() => (window as any).__terminalStream.enqueue(JSON.stringify({ ++ event: "error", message: "测试结果生成失败", status: 422, ++ }) + "\n")); ++ await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled({ timeout: 3000 }); ++ await expect(page.getByRole("complementary", { name: "仿真控制台", exact: true })).toContainText("测试结果生成失败"); ++ await expectReleasedStream(page); ++ expect(errors).toEqual([]); ++}); ++ ++for (const truncated of [false, true]) { ++ test(`EOF ${truncated ? "rejects truncated JSON" : "accepts a complete result without a trailing newline"}`, async ({ page }) => { ++ await prepareOpenStream(page); ++ await page.evaluate(({ result, truncated }) => { ++ const probe = (window as any).__terminalStream; ++ const line = JSON.stringify({ event: "result", result }); ++ probe.enqueue(truncated ? line.slice(0, -1) : line); ++ probe.close(); ++ }, { result: resultSnapshot.result, truncated }); ++ await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled({ timeout: 3000 }); ++ const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true }); ++ await expect(panel).toContainText(truncated ? "仿真失败" : "仿真完成,已生成新的结果"); ++ expect(await page.evaluate(() => { ++ const probe = (window as any).__terminalStream; ++ return { aborted: probe.signal.aborted, locked: probe.body.locked, closedByServer: probe.closedByServer }; ++ })).toEqual({ aborted: true, locked: false, closedByServer: true }); ++ }); ++} ++ ++for (const invalidTrailer of [false, true]) { ++ test(`encoded result ${invalidTrailer ? "rejects a later decoding error" : "waits for validated EOF before publication"}`, async ({ page }) => { ++ const errors: string[] = []; ++ page.on("pageerror", (error) => errors.push(error.message)); ++ await prepareOpenStream(page, "pending", "gzip"); ++ // Fetch exposes decoded bytes while retaining Content-Encoding. This mock ++ // represents the browser delivering JSON before validating gzip's trailer; ++ // gzip wire encoding itself belongs to the HTTP/backend integration tests. ++ await page.evaluate((result) => { ++ (window as any).__terminalStream.enqueue(JSON.stringify({ event: "progress", phase: "complete", ++ progress: 100, simulatedTime: 10, totalTime: 10, message: "正在汇总仿真结果" }) + "\n" + ++ JSON.stringify({ event: "result", result }) + "\n"); ++ }, resultSnapshot.result); ++ const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true }); ++ const run = page.getByRole("button", { name: "运行仿真", exact: true }); ++ await expect(panel).toContainText("正在汇总仿真结果"); ++ await expect(run).toBeDisabled(); ++ expect(await page.evaluate(() => { ++ const probe = (window as any).__terminalStream; ++ return { locked: probe.body.locked, aborted: probe.signal.aborted }; ++ })).toEqual({ locked: true, aborted: false }); ++ await page.evaluate((invalidTrailer) => { ++ const probe = (window as any).__terminalStream; ++ if (invalidTrailer) probe.fail("gzip trailer checksum failed"); ++ else probe.close(); ++ }, invalidTrailer); ++ await expect(run).toBeEnabled({ timeout: 3000 }); ++ if (invalidTrailer) { ++ await expect(panel).toContainText("gzip trailer checksum failed"); ++ await expect(panel).not.toContainText("仿真完成,已生成新的结果"); ++ expect(await page.evaluate(() => sessionStorage.getItem("system-simulation-flow:latest-result"))).toBeNull(); ++ } else { ++ await expect(panel).toContainText("仿真完成,已生成新的结果"); ++ await expect(panel).not.toContainText("仿真失败"); ++ await page.getByRole("tab", { name: /^结果/ }).click(); ++ await expect(page.getByRole("button", { name: "下载结果文件", exact: true })).toBeVisible(); ++ } ++ expect(await page.evaluate(() => { ++ const probe = (window as any).__terminalStream; ++ return { locked: probe.body.locked, aborted: probe.signal.aborted, unhandled: probe.unhandled }; ++ })).toEqual({ locked: false, aborted: true, unhandled: [] }); ++ expect(errors).toEqual([]); ++ }); ++} +--- /dev/null ++++ b/tests/test_simulation_result_compression.py +@@ -0,0 +1,251 @@ ++"""Lossless, live ASGI result compression without a model or HTTP client. ++ ++The wire oracle is Python's independent gzip/zlib decoder. Tests assert emitted ++bytes and request/response semantics, not the compressor's internal algorithm. ++""" ++from __future__ import annotations ++ ++import asyncio ++import gzip ++import hashlib ++import unittest ++import zlib ++ ++from app.result_compression import SimulationResultCompressionMiddleware ++ ++ ++STREAM_PATH = '/api/system-xml/simulate-stream' ++CHUNK_BYTES = 256 * 1024 ++ ++ ++def http_scope(method='POST', path=STREAM_PATH, accept_encoding='gzip'): ++ return { ++ 'type': 'http', 'asgi': {'version': '3.0', 'spec_version': '2.4'}, ++ 'http_version': '1.1', 'method': method, 'scheme': 'http', ++ 'path': path, 'raw_path': path.encode(), 'query_string': b'', ++ 'root_path': '', 'server': ('testserver', 80), ++ 'client': ('127.0.0.1', 1234), ++ 'headers': ([] if accept_encoding is None else ++ [(b'accept-encoding', value.encode('ascii')) ++ for value in ([accept_encoding] if isinstance(accept_encoding, str) else accept_encoding)]), ++ } ++ ++ ++def copied_message(message): ++ result = dict(message) ++ if 'headers' in result: ++ result['headers'] = list(result['headers']) ++ return result ++ ++ ++def response_headers(messages): ++ return dict(next(m['headers'] for m in messages if m['type'] == 'http.response.start')) ++ ++ ++def response_bodies(messages): ++ return [m for m in messages if m['type'] == 'http.response.body'] ++ ++ ++async def receive(): ++ return {'type': 'http.request', 'body': b'', 'more_body': False} ++ ++ ++class SimulationResultCompressionTests(unittest.IsolatedAsyncioTestCase): ++ async def request(self, body, *, method='POST', path=STREAM_PATH, ++ accept_encoding='gzip', status=200, headers=None): ++ original = [ ++ {'type': 'http.response.start', 'status': status, ++ 'headers': list(headers if headers is not None else [ ++ (b'content-type', b'application/json'), ++ (b'content-length', str(len(body)).encode()), ++ ])}, ++ {'type': 'http.response.body', 'body': body, 'more_body': False}, ++ ] ++ async def application(scope, incoming, send): ++ self.assertEqual(scope['method'], method) ++ for message in original: ++ await send(copied_message(message)) ++ messages = [] ++ async def send(message): ++ messages.append(copied_message(message)) ++ await SimulationResultCompressionMiddleware(application)( ++ http_scope(method, path, accept_encoding), receive, send) ++ return messages, original ++ ++ def assert_complete_gzip(self, messages, expected): ++ encoded = b''.join(m.get('body', b'') for m in response_bodies(messages)) ++ self.assertEqual(gzip.decompress(encoded), expected) ++ decoder = zlib.decompressobj(zlib.MAX_WBITS + 16) ++ self.assertEqual(decoder.decompress(encoded) + decoder.flush(), expected) ++ self.assertTrue(decoder.eof, 'The gzip trailer must be complete') ++ self.assertEqual(decoder.unused_data, b'', 'No bytes may follow the gzip stream') ++ self.assertEqual(decoder.unconsumed_tail, b'') ++ headers = response_headers(messages) ++ self.assertEqual(headers[b'content-encoding'], b'gzip') ++ vary = [part.strip().lower() for part in headers.get(b'vary', b'').split(b',')] ++ self.assertIn(b'accept-encoding', vary) ++ if b'content-length' in headers: ++ self.assertEqual(int(headers[b'content-length']), len(encoded)) ++ bodies = response_bodies(messages) ++ self.assertTrue(bodies) ++ self.assertFalse(bodies[-1].get('more_body', False)) ++ self.assertTrue(all(m.get('more_body', False) for m in bodies[:-1])) ++ ++ async def test_progress_is_decodable_before_application_sends_result(self): ++ progress = '{"event":"progress","message":"正在计算雪的温度","progress":20}\n'.encode() ++ result = ('{"event":"result","series":{"x":[5e-324,-0,-0.0,1e-300]},' ++ '"message":"温度与压力\\n已完成","padding":"' + 'x' * (CHUNK_BYTES + 200) + '"}\n').encode() ++ decoder = zlib.decompressobj(zlib.MAX_WBITS + 16) ++ received = bytearray() ++ messages = [] ++ result_started = False ++ async def send(message): ++ messages.append(copied_message(message)) ++ if message['type'] == 'http.response.body': ++ received.extend(decoder.decompress(message.get('body', b''))) ++ if not result_started: ++ self.assertFalse(decoder.eof) ++ async def application(scope, incoming, send): ++ await send({'type': 'http.response.start', 'status': 200, 'headers': [ ++ (b'content-type', b'application/x-ndjson'), ++ (b'cache-control', b'no-cache, no-transform'), ++ (b'x-accel-buffering', b'no'), (b'vary', b'Origin'), ++ ]}) ++ await send({'type': 'http.response.body', 'body': progress, 'more_body': True}) ++ self.assertEqual(bytes(received), progress, ++ 'Progress must be readable before the app computes/sends its result') ++ nonlocal result_started ++ result_started = True ++ # Arbitrary UTF-8/JSON boundaries must not change a byte. ++ utf8_split = result.index('温度'.encode()) + 1 ++ for body in (result[:7], result[7:utf8_split], result[utf8_split:]): ++ await send({'type': 'http.response.body', 'body': body, 'more_body': True}) ++ await send({'type': 'http.response.body', 'body': b'', 'more_body': False}) ++ await SimulationResultCompressionMiddleware(application)(http_scope(), receive, send) ++ self.assertEqual(bytes(received), progress + result) ++ self.assertTrue(decoder.eof) ++ self.assert_complete_gzip(messages, progress + result) ++ headers = response_headers(messages) ++ self.assertNotIn(b'content-length', headers) ++ self.assertEqual(headers[b'cache-control'], b'no-cache, no-transform') ++ self.assertEqual(headers[b'x-accel-buffering'], b'no') ++ self.assertIn(b'origin', headers[b'vary'].lower()) ++ ++ async def test_large_single_body_is_split_and_preserves_entire_trailer(self): ++ # Deterministic high-entropy ASCII avoids a trivially tiny compressed ++ # result hiding an accidental one-shot write of the whole large body. ++ body = b'{"opaque":"' + hashlib.shake_256(b'asgi-result').hexdigest(600 * 1024).encode() + b'"}' ++ messages, _ = await self.request(body) ++ self.assert_complete_gzip(messages, body) ++ chunks = [m['body'] for m in response_bodies(messages) if m.get('body')] ++ self.assertGreaterEqual(len(chunks), 4) ++ # DEFLATE may enlarge incompressible input slightly, so compressed ++ # messages are allowed framing overhead beyond the 256 KiB input cap. ++ self.assertLessEqual(max(map(len, chunks)), CHUNK_BYTES + 4096) ++ self.assertNotIn(b'content-length', response_headers(messages)) ++ ++ async def test_gzip_negotiation_and_supported_result_routes(self): ++ body = b'{"result":"' + b'large-result,' * 100 + b'"}' ++ for method, path in [('POST', STREAM_PATH), ('POST', '/api/system-xml/simulate'), ++ ('GET', '/api/system-xml/simulations/task-123')]: ++ for encoding in ('gzip', 'br, gzip;q=0.5', '*', '*;q=0.5', 'GZip;Q=0.7', ++ 'gzip;q=0.5, *;q=0', 'gzip;q=0.8, identity;q=0.5', ++ ['br', 'gzip;q=0.5']): ++ with self.subTest(method=method, path=path, encoding=encoding): ++ messages, _ = await self.request(body, method=method, path=path, ++ accept_encoding=encoding) ++ self.assert_complete_gzip(messages, body) ++ ++ async def test_nonaccepting_clients_and_unrelated_routes_are_unchanged(self): ++ body = b'{"result":"' + b'x' * 2000 + b'"}' ++ for encoding in (None, '', 'identity', 'br', 'gzip;q=0', 'gzip;q=0.000', ++ '*;q=0', 'gzip;q=0, *;q=1', 'GZIP;Q=0', 'notgzip', ++ 'gzip;q=bogus', 'gzip;q=1.001', 'gzip;q=0.1234', ++ 'gzip;q=0.8, identity;q=1', ['gzip', 'gzip;q=0']): ++ with self.subTest(encoding=encoding): ++ messages, original = await self.request(body, accept_encoding=encoding) ++ # Identity negotiation still varies by Accept-Encoding for ++ # shared caches; its status, existing headers and bytes stay exact. ++ headers = response_headers(messages) ++ self.assertNotIn(b'content-encoding', headers) ++ self.assertIn(b'accept-encoding', headers[b'vary'].lower()) ++ normalized = [copied_message(message) for message in messages] ++ start = next(message for message in normalized if message['type'] == 'http.response.start') ++ start['headers'] = [(key, value) for key, value in start['headers'] if key.lower() != b'vary'] ++ self.assertEqual(normalized, original) ++ for method, path in [('GET', STREAM_PATH), ('POST', '/api/system-xml/validate'), ++ ('POST', '/api/system-xml/simulations/task/cancel'), ++ ('GET', '/api/system-xml/simulations/task/cancel'), ++ ('GET', '/api/system-xml/simulations'), ('GET', '/')]: ++ with self.subTest(method=method, path=path): ++ messages, original = await self.request(body, method=method, path=path) ++ self.assertEqual(messages, original) ++ ++ async def test_small_preencoded_and_partial_responses_are_not_compressed(self): ++ tiny = b'{"status":"queued"}' ++ messages, original = await self.request(tiny) ++ self.assertEqual(messages, original) ++ body = b'{"data":"' + b'x' * (CHUNK_BYTES + 50) + b'"}' ++ for status, extra in [(200, [(b'content-encoding', b'br')]), ++ (200, [(b'content-encoding', b'gzip')]), ++ (206, [(b'content-range', b'bytes 0-100/2000')])]: ++ with self.subTest(status=status, extra=extra): ++ headers = [(b'content-type', b'application/json'), ++ (b'content-length', str(len(body)).encode()), *extra] ++ messages, original = await self.request(body, status=status, headers=headers) ++ self.assertEqual(messages, original) ++ ++ async def test_error_and_cancelled_results_preserve_status_and_numeric_text(self): ++ for code, status in [(422, 'failed'), (200, 'stopped'), (200, 'stalled')]: ++ with self.subTest(code=code, status=status): ++ body = ('{"status":"' + status + '","partial":true,"series":' ++ '{"温度":[-0,5e-324,-0.0,1.7976931348623157e308]},' ++ '"message":"' + '保留已接受的部分结果。' * 80 + '"}').encode() ++ messages, _ = await self.request(body, status=code) ++ self.assertEqual(next(m['status'] for m in messages if m['type'] == 'http.response.start'), code) ++ self.assert_complete_gzip(messages, body) ++ ++ async def test_application_failures_and_cancellation_are_not_swallowed(self): ++ progress = b'{"event":"progress","progress":20}\n' ++ for exception in (RuntimeError('upstream failed'), asyncio.CancelledError()): ++ with self.subTest(exception=type(exception).__name__): ++ messages = [] ++ async def send(message): ++ messages.append(copied_message(message)) ++ async def application(scope, incoming, send): ++ await send({'type': 'http.response.start', 'status': 200, ++ 'headers': [(b'content-type', b'application/x-ndjson')]}) ++ await send({'type': 'http.response.body', 'body': progress, 'more_body': True}) ++ raise exception ++ with self.assertRaises(type(exception)) as caught: ++ await SimulationResultCompressionMiddleware(application)(http_scope(), receive, send) ++ self.assertIs(caught.exception, exception) ++ decoder = zlib.decompressobj(zlib.MAX_WBITS + 16) ++ partial = b''.join(m.get('body', b'') for m in response_bodies(messages)) ++ self.assertEqual(decoder.decompress(partial), progress) ++ self.assertFalse(decoder.eof, 'A failed stream must not masquerade as normally completed') ++ ++ async def test_downstream_disconnect_propagates_and_unwinds_application(self): ++ unwound = False ++ failure = BrokenPipeError('client disconnected') ++ async def application(scope, incoming, send): ++ nonlocal unwound ++ try: ++ await send({'type': 'http.response.start', 'status': 200, ++ 'headers': [(b'content-type', b'application/x-ndjson')]}) ++ await send({'type': 'http.response.body', 'body': b'{"event":"progress"}\n', ++ 'more_body': True}) ++ self.fail('The application must observe the failed downstream send') ++ finally: ++ unwound = True ++ async def send(message): ++ if message['type'] == 'http.response.body': ++ raise failure ++ with self.assertRaises(BrokenPipeError) as caught: ++ await SimulationResultCompressionMiddleware(application)(http_scope(), receive, send) ++ self.assertIs(caught.exception, failure) ++ self.assertTrue(unwound) ++ ++ ++if __name__ == '__main__': ++ unittest.main() +--- /dev/null ++++ b/tests/manual/result_transfer_profile.py +@@ -0,0 +1,250 @@ ++"""Serve a real app with optional throttling AFTER its content compression. ++ ++ .venv/bin/python tests/manual/result_transfer_profile.py \ ++ --source-root . --frontend-dist frontend/dist --port 8012 \ ++ --output-dir test/result-transfer/new --download-bytes-per-second 1600000 ++ ++Only /api/system-xml/simulate-stream response bodies are split into <=64 KiB ++chunks. Each encoded chunk is delayed before the original ASGI send using ++max(now, previous_deadline) + chunk_bytes/rate; solver idle time earns no credit. ++Rate 0 preserves the same chunking without deliberate delays. No solver hooks, ++request-body copies, response parsing, browser launch, or compilation occurs. ++""" ++from __future__ import annotations ++ ++import argparse ++import asyncio ++from hashlib import sha256 ++import importlib ++import json ++import logging ++import math ++import os ++from pathlib import Path ++import re ++import shutil ++import sys ++import time ++from uuid import uuid4 ++ ++ROOT = Path(__file__).resolve().parents[2] ++STREAM_PATH = "/api/system-xml/simulate-stream" ++CHUNK_BYTES = 64 * 1024 ++ ++ ++def write_json(path: Path, value: dict) -> None: ++ path.write_text(json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8") ++ ++ ++def digest(path: Path) -> str: ++ return sha256(path.read_bytes()).hexdigest() ++ ++ ++class TransferProfile: ++ """An outer ASGI wrapper; outgoing body bytes are already content-encoded.""" ++ ++ def __init__(self, app, output: Path, rate: int, *, clock=time.perf_counter, sleep=asyncio.sleep): ++ if type(rate) is not int or rate < 0: ++ raise ValueError("download bytes per second must be a nonnegative integer") ++ self.app = app ++ self.output = output ++ self.rate = rate ++ self.clock = clock ++ self.sleep = sleep ++ ++ async def __call__(self, scope, receive, send): ++ if scope["type"] != "http" or scope.get("path") != STREAM_PATH: ++ return await self.app(scope, receive, send) ++ request_headers = dict(scope.get("headers", [])) ++ sid = request_headers.get(b"x-simulation-id", b"").decode("latin-1") ++ request_id = sid if re.fullmatch(r"[A-Za-z0-9_-]{1,128}", sid) else uuid4().hex ++ directory = self.output / "requests" / request_id ++ try: ++ directory.mkdir(parents=True, exist_ok=False) ++ except FileExistsError: ++ request_id += "-" + uuid4().hex ++ directory = self.output / "requests" / request_id ++ directory.mkdir(parents=True, exist_ok=False) ++ started = self.clock() ++ deadline = started ++ record = { ++ "schemaVersion": 1, "requestId": request_id, "simulationId": sid or None, ++ "path": scope["path"], "method": scope.get("method"), "httpVersion": scope.get("http_version"), ++ "asgiSpecVersion": scope.get("asgi", {}).get("spec_version"), ++ "downloadBytesPerSecond": self.rate, "maxChunkBytes": CHUNK_BYTES, ++ "requestAcceptEncoding": request_headers.get(b"accept-encoding", b"").decode("latin-1"), ++ "status": "incomplete", "responseHeaders": [], "httpStatus": None, ++ "encodedBodyBytes": 0, "attemptedEncodedBodyBytes": 0, ++ "sourceBodyMessages": 0, "chunkCount": 0, "nonEmptyChunkCount": 0, ++ "maxObservedChunkBytes": 0, "throttleSleepSeconds": 0.0, "sendAwaitSeconds": 0.0, ++ "responseHeadersSeconds": None, "firstBodyAvailableSeconds": None, ++ "firstNonemptyBodyAvailableSeconds": None, "firstBodySendStartSeconds": None, ++ "firstBodySendEndSeconds": None, "lastBodySendStartSeconds": None, ++ "lastBodySendEndSeconds": None, "responseBodyCompleteSeconds": None, ++ "disconnectObserved": False, "disconnectBeforeBodyComplete": False, ++ "disconnectObservedSeconds": None, "sendDisconnectException": None, ++ "applicationReturned": False, "exception": None, ++ } ++ body_complete = False ++ active_exception = None ++ ++ def elapsed() -> float: ++ return self.clock() - started ++ ++ async def observed_receive(): ++ message = await receive() ++ if message["type"] == "http.disconnect": ++ record["disconnectObserved"] = True ++ if record["disconnectObservedSeconds"] is None: ++ record["disconnectObservedSeconds"] = elapsed() ++ # Uvicorn may report http.disconnect after a normally finished response. ++ if not body_complete: ++ record["disconnectBeforeBodyComplete"] = True ++ return message ++ ++ async def send_chunk(message, chunk: bytes, more_body: bool): ++ nonlocal deadline, body_complete ++ length = len(chunk) ++ if self.rate and length: ++ now = self.clock() ++ deadline = max(now, deadline) + length / self.rate ++ pause_start = self.clock() ++ try: ++ await self.sleep(max(0.0, deadline - pause_start)) ++ finally: ++ record["throttleSleepSeconds"] += self.clock() - pause_start ++ start = elapsed() ++ if length and record["firstBodySendStartSeconds"] is None: ++ record["firstBodySendStartSeconds"] = start ++ record["lastBodySendStartSeconds"] = start ++ record["attemptedEncodedBodyBytes"] += length ++ try: ++ await send({**message, "body": chunk, "more_body": more_body}) ++ except OSError as exc: ++ record["sendDisconnectException"] = {"type": type(exc).__name__, "message": str(exc)[:1000]} ++ record["disconnectBeforeBodyComplete"] = not body_complete ++ raise ++ finally: ++ record["sendAwaitSeconds"] += elapsed() - start ++ end = elapsed() ++ record["encodedBodyBytes"] += length ++ record["chunkCount"] += 1 ++ record["nonEmptyChunkCount"] += int(length > 0) ++ record["maxObservedChunkBytes"] = max(record["maxObservedChunkBytes"], length) ++ if length and record["firstBodySendEndSeconds"] is None: ++ record["firstBodySendEndSeconds"] = end ++ record["lastBodySendEndSeconds"] = end ++ if not more_body: ++ body_complete = True ++ record["responseBodyCompleteSeconds"] = end ++ ++ async def observed_send(message): ++ if message["type"] == "http.response.start": ++ record["responseHeadersSeconds"] = elapsed() ++ record["httpStatus"] = message["status"] ++ record["responseHeaders"] = [[key.decode("latin-1"), value.decode("latin-1")] ++ for key, value in message.get("headers", [])] ++ headers = dict(message.get("headers", [])) ++ record["contentEncoding"] = headers.get(b"content-encoding", b"").decode("latin-1") or None ++ record["contentType"] = headers.get(b"content-type", b"").decode("latin-1") or None ++ return await send(message) ++ if message["type"] != "http.response.body": ++ return await send(message) ++ body = message.get("body", b"") ++ record["sourceBodyMessages"] += 1 ++ if record["firstBodyAvailableSeconds"] is None: ++ record["firstBodyAvailableSeconds"] = elapsed() ++ if body and record["firstNonemptyBodyAvailableSeconds"] is None: ++ record["firstNonemptyBodyAvailableSeconds"] = elapsed() ++ more_body = message.get("more_body", False) ++ if not body: ++ await send_chunk(message, b"", more_body) ++ return ++ for offset in range(0, len(body), CHUNK_BYTES): ++ end = min(offset + CHUNK_BYTES, len(body)) ++ await send_chunk(message, body[offset:end], more_body or end < len(body)) ++ ++ try: ++ await self.app(scope, observed_receive, observed_send) ++ record["applicationReturned"] = True ++ except BaseException as exc: ++ active_exception = exc ++ record["exception"] = {"type": type(exc).__name__, "message": str(exc)[:1000]} ++ raise # Preserve disconnect, cancellation and application failures. ++ finally: ++ record["responseSeconds"] = elapsed() ++ record["responseBodyComplete"] = body_complete ++ if record["disconnectBeforeBodyComplete"] or record["sendDisconnectException"]: ++ record["status"] = "disconnected" ++ elif active_exception is not None: ++ record["status"] = "cancelled" if isinstance(active_exception, asyncio.CancelledError) else "failed" ++ elif body_complete: ++ record["status"] = "completed" ++ try: ++ write_json(directory / "transfer.json", record) ++ except OSError: ++ if active_exception is None: ++ raise ++ logging.exception("Could not save transfer metadata while propagating the original exception") ++ ++ ++def main() -> None: ++ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ++ parser.add_argument("--source-root", type=Path, default=ROOT, help="Repository or frozen source snapshot containing app/main.py") ++ parser.add_argument("--frontend-dist", type=Path, help="Default: SOURCE_ROOT/frontend/dist; copied to the fresh output directory") ++ parser.add_argument("--port", type=int, default=8012) ++ parser.add_argument("--output-dir", type=Path, required=True) ++ parser.add_argument("--download-bytes-per-second", type=int, default=0, help="Encoded HTTP body bytes/s; 0 disables deliberate delays") ++ args = parser.parse_args() ++ source, output = args.source_root.resolve(), args.output_dir.resolve() ++ frontend = (args.frontend_dist or source / "frontend/dist").resolve() ++ if not (source / "app/main.py").is_file(): ++ parser.error("--source-root must contain app/main.py") ++ if not (frontend / "index.html").is_file(): ++ parser.error("--frontend-dist must contain index.html") ++ if output.exists() or output.is_relative_to(frontend): ++ parser.error("Choose a fresh output directory outside --frontend-dist") ++ if not 1 <= args.port <= 65535 or args.download_bytes_per_second < 0: ++ parser.error("Require port 1..65535 and download bytes per second >= 0") ++ if not math.isfinite(float(args.download_bytes_per_second)): ++ parser.error("Download rate must be finite") ++ output.mkdir(parents=True) ++ shutil.copytree(frontend, output / "frontend") ++ # These timing-only inherited switches must not accidentally instrument a run. ++ for name in ("SIMULATIONAPP_PROFILE", "NATIVE_COMPUTE_PROFILE", "NATIVE_STAGE_PROFILE"): ++ os.environ.pop(name, None) ++ sys.path.insert(0, str(source)) ++ api = importlib.import_module("app.main") ++ if Path(api.__file__).resolve() != source / "app/main.py": ++ raise RuntimeError("Imported app.main does not belong to the requested source root") ++ api.FRONTEND_DIST_DIR = output / "frontend" ++ import uvicorn ++ ++ metadata = { ++ "schemaVersion": 1, "mode": "encoded-body-rate-limit", "sourceRoot": str(source), ++ "loadedApp": str(Path(api.__file__).resolve()), "appMainSha256": digest(source / "app/main.py"), ++ "scriptSha256": digest(Path(__file__)), "frontendDist": str(frontend), ++ "frontendFiles": {str(p.relative_to(output / "frontend")): digest(p) ++ for p in sorted((output / "frontend").rglob("*")) if p.is_file()}, ++ "python": sys.version, "platform": sys.platform, "uvicorn": uvicorn.__version__, ++ "host": "127.0.0.1", "port": args.port, ++ "downloadBytesPerSecond": args.download_bytes_per_second, "maxChunkBytes": CHUNK_BYTES, ++ "limitedPath": STREAM_PATH, "solverInstrumented": False, ++ "definitions": { ++ "rate": "Each nonempty encoded chunk is delayed before ASGI send with deadline=max(now,last_deadline)+chunk_bytes/rate; no accumulated credit during computation or socket backpressure. 0 means no deliberate delay, with the same <=64KiB chunking.", ++ "encodedBodyBytes": "Body bytes after application content compression whose original ASGI send returned successfully. Includes progress/result/newline bytes; excludes HTTP framing, response headers, TCP/TLS and port-forward overhead. Not proof of client receipt.", ++ "chunkCount": "Successfully sent ASGI body messages, including empty messages. nonEmptyChunkCount excludes them.", ++ "bodyTimes": "Seconds since this request entered the outer middleware. firstBodySend* refers to first nonempty chunk; lastBodySend* includes an empty terminal body. responseSeconds ends when the application returns/raises, before metadata file I/O.", ++ "completion": "completed means the final more_body=false ASGI send returned and the app returned normally. It does not mean the browser parsed or rendered the result.", ++ "disconnect": "Original receive messages and raised exceptions are preserved. Disconnect observed after a completed body is separately recorded but does not relabel a normal response. Before-completion disconnect, cancellation and failure are distinct statuses.", ++ "scope": "Only the simulation streaming route is throttled. This harness models encoded HTTP body throughput, not latency, packet loss or an actual remote tunnel. Current/frozen applications retain their own compression policy.", ++ }, ++ } ++ write_json(output / "environment.json", metadata) ++ print(json.dumps({"url": f"http://127.0.0.1:{args.port}", "output": str(output), ++ "downloadBytesPerSecond": args.download_bytes_per_second, "sourceRoot": str(source)}), flush=True) ++ uvicorn.run(TransferProfile(api.app, output, args.download_bytes_per_second), host="127.0.0.1", port=args.port) ++ ++ ++if __name__ == "__main__": ++ main() diff --git a/docs/other/雅可比算法正式启用与网页验收-2026-09-11.md b/docs/other/雅可比算法正式启用与网页验收-2026-09-11.md new file mode 100644 index 0000000..122d66a --- /dev/null +++ b/docs/other/雅可比算法正式启用与网页验收-2026-09-11.md @@ -0,0 +1,61 @@ +# 雅可比算法正式启用与网页验收 + +日期:2026-09-11。用户已确认接受试验版的数值误差,并要求正式网页使用新版、清理旧算法。上一轮最大温度误差相对较严格 native 参考为 0.0468 → 0.0543 K;接受该差异不等同于新增八路 AMESim 外部曲线验收。 + +## 正式行为 + +网页/API、Python runner 和独立 C 程序统一自动选择有收益的结构着色差分。八路仍为 132 状态、27 组扰动 + 1 次 canonical 基准,沿用已验证的物理方程、普通 RHS、`rtol=1e-8`、状态 atol、采样与 Dense LU。 + +删除 `SIMULATION_NATIVE_JACOBIAN` 和 `--jacobian dense|auto|verify` 的生产选择入口,旧 CLI 选项会明确以 64 退出。不存在需要用户开启的试验开关。独立 C 程序保留诊断无值标志 `--verify-jacobian`,按完整矩阵核对新算法,不用于正式速度比较。 + +不支持结构证明、没有分组收益的模型保留必要的逐列差分;分组扰动失败、核对失配时保留 canonical 逐列恢复。它们是新算法的兼容/恢复路径,不是独立可选择的旧版本。紧凑模型、未知结构、取消、事件重启都纳入检查。 + +## 实现与历史对照 + +运行时不再保存旧算法模式;构建 manifest 直接说明默认策略。Python 不再从环境变量选择旧算法。手动性能工具默认测新算法,仅通过显式提供冻结的旧可执行文件做历史对照;正式源码不保留旧版本选择器。 + +原始试验数据和误差不重写,详见 [完整试验报告](雅可比结构着色试验与八路验证-2026-09-11.md)。本轮只是将用户已接受的候选设为默认并清理入口,需验证正式输出与该候选一致。 + +## 本轮验证记录 + +57 项后端与雅可比专项测试全部通过,用时 129.667 s,覆盖元件目录冻结基准、两种积分器、结构依赖、canonical 差分核对、失败恢复、事件重启、结果直传、取消及旧 CLI 选项拒绝。四路 72 条 AMESim 参考曲线沿用原门槛通过,并确认默认 `colored-difference`、15 组 + 1 次基准、无回退。 + +```sh +.venv/bin/python -m unittest tests.test_native_codegen tests.test_native_schedule tests.test_native_catalog tests.test_native_pipe_physics tests.test_native_only_backend tests.test_native_result_transport tests.test_generic_system_xml_simulation tests.test_native_jacobian_structure tests.test_native_jacobian_runtime -v +``` + +单独通过正式构建程序执行新 `--verify-jacobian` 标志,八路 0~0.0001 s 完成 16 次完整矩阵核对,零失配、零回退。它检查新诊断入口的实际 CLI 行为;完整 0~10 s 的 433 次核对已在前一轮完成,本轮不混用两个次数。 + +手动工具完成语法、参数、真实八路 prepare-only、隔离插桩准备与进程编排检查。它们的旧历史成本汇总重算后,除脚本 SHA 外逐字段不变,没有重写旧数据。 + +原始产物全部在 Git 忽略的 `test/jacobian-production-20260911/`,轻量 [正式启用摘要](assets/2026-09-11/jacobian-production-summary.json) 随文档保留,包括来源 SHA、默认策略、计数和用时。 + +## 正式网页验证 + +通过原有 `bat/start-all.sh` 重启当前正式 FastAPI 与前端。实际入口: + +- [正常编辑器入口](http://127.0.0.1:5173/):经原有代理调用 8000 后端。 +- [正式构建页面](http://127.0.0.1:8000/):同一后端提供现有生产静态资源。 + +没有另开需要指定算法环境变量的试验实例。正式页面在 8000 端口执行 1 次预热 + 3 次串行测量;5173 端口另做 1 次完整操作检查。5173 使用 Vite 开发资源,仅用于入口连通性与功能验证,不与生产资源的性能基准混合。 + +主模型为修正八路 `tests/data/test-mql-8-corrected.json`,输入 SHA 与前一轮一致;8000 实际加载的前端资源集合 SHA 仍为 `f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2`。模型导入后元件参数、端口连接、仿真设置的公开导出核对通过。 + +| 正式页面过程 | 三次中位 | +| --- | ---: | +| C 求解 | 2.6066 s | +| 点击运行 → 结果可查看 | 3.4985 s | +| 点击运行 → 浏览器保存完成(观察值) | 3.6134 s | +| CSV 点击 → 下载保存完成 | 1.1997 s | +| 结果文件点击 → 下载保存完成 | 1.1057 s | + +时间口径与试验报告一致:浏览器保存为 IndexedDB 提交/指针可见的观察时刻,含轮询与调度;文件保存包括浏览器通知和自动化本地 `saveAs`。各过程不相加,也不把新的单组样本当作再次测量旧版的配对加速比。 + + +首次构建单次 4.2035 s,首次点击到可查看 7.6946 s;它不进入三次已命中缓存的正式中位数。 + +5 次完整网页运行均自动报告 `colored-difference`:`nfev=22,853`、`njev=433`、`jacobianRhsCalls=12,124`、`jacobianFallbacks=0`、接受步 6,660、拒绝步 359、事件 1、求解器启动 4,与用户已接受的候选一致。 + +计时后对 8000 的全部 4 次结果、最终输出、CSV 与前一轮已接受 native 候选逐数值比较;1,002 点 × 1,785 列 × 4,共 7,154,280 个 CSV 单元全部一致。5173 的单次结果/最终输出也与候选一致,CSV 与已核验正式 CSV 字节完全相同。5 次刷新恢复后的完整结果均一致,没有浏览器异常。[8000 核对记录](../../test/jacobian-production-20260911/equality-official.json) + +新生成 C/头文件的数值内容和结构与已接受候选相同;生产运行时只改默认选择和诊断入口。完整来源、构建缓存、测试日志和浏览器下载保存在上述忽略目录;本轮未安装环境或更改模型、积分精度、物理方程、结果编码和前端功能。 diff --git a/docs/other/雅可比结构着色试验与八路验证-2026-09-11.md b/docs/other/雅可比结构着色试验与八路验证-2026-09-11.md new file mode 100644 index 0000000..b7c16c5 --- /dev/null +++ b/docs/other/雅可比结构着色试验与八路验证-2026-09-11.md @@ -0,0 +1,322 @@ +# 雅可比结构着色试验与八路验证 + +日期:2026-09-11。基线:已提交并推送的 `3bc4be3c061898d130be9b2b3cd633e9573acf04`(原生结果编码、传输与浏览器缓存优化)。 + +> 后续状态更新:用户已确认接受本文披露的误差,结构着色差分现已转为正式默认,旧策略选择器已经删除。下文保留启用前的试验结论、命令与数据,其“默认 dense / 显式启用”描述仅对应试验阶段。当前运行方式与正式网页核验见 [正式启用报告](雅可比算法正式启用与网页验收-2026-09-11.md)。 + +**交付定位:可运行的试验版,默认继续使用 CVODE 原有逐列稠密差分。** 当前八路试验显著减少了构造雅可比所需的系统求值,但较严格积分结果的交叉比较仍发现温度局部误差略增,因此不能宣称已经通过等精度替换验收。CLI 和独立预览服务可显式启用着色模式;模型文件及通常网页/API 默认行为保持原有 `dense` 策略。 + +**正式结果:** 八路原生求解中位 5.7915 → 2.4926 s;网页点击到结果可查看 7.0185 → 3.4556 s,浏览器保存 7.1597 → 3.5793 s。16 次网页/CSV/恢复核对通过。相对严格数值参考的最大温度误差 0.0468 → 0.0543 K,试验版保留显式启用。 + +## 1. 固定案例与优化对象 + +主案例为 `tests/data/test-mql-8-corrected.json`,SHA-256 为 `670977bef67e62d9c66e8af497bada208bd72a7301be45128d185d47282cf288`。使用 SUNDIALS 7.4.0 的 CVODE/BDF,时间范围 0–10 s,输出间隔 0.01 s,`rtol=1e-8`、`maxStep=1e30`。状态绝对容差沿用现有按物理量设置的向量:质量 `1e-14 kg`、能量 `1e-8 J`、机械位置和速度 `1e-12`(各自单位)。这些配置、密集矩阵存储、Dense LU、物理方程和管流局部求根均未因本试验调整。 + +本轮只优化系统雅可比 `J=∂f/∂y` 的差分构造。基线八路有 132 个状态;一次默认雅可比构造需要逐列扰动 132 次,每次都求值整个系统 RHS。初测基线的 475 次雅可比构造产生 `475×132=62,700` 次线性求解器差分 RHS,占 `nfev=74,265` 的大部分。**这是调用次数占比,不是 CPU 时间占比。** + +本轮仍把完整矩阵交给原来的 Dense LU。结构稀疏性在这里用于合并互不影响的差分扰动,尚未引入稀疏矩阵分解或更换积分方法。 + +## 2. 官方默认算法与保留内容 + +CVODE/BDF 每步通过非线性迭代求解离散方程,Newton 线性系统使用近似矩阵 `M=I−γJ`。提供自定义 `CVLsJacFn` 时,应返回 `J`,后续矩阵变换和线性求解仍由 CVODE 完成。本项目保留其矩阵更新、步长和误差控制流程。[SUNDIALS 7.4 数学说明](https://sundials.readthedocs.io/en/v7.4.0/cvode/Mathematics_link.html#nonlinear-solve)、[雅可比回调接口](https://sundials.readthedocs.io/en/v7.4.0/cvode/Usage/index.html#c.CVLsJacFn) + +本地核对版本为 `sundials-7.4.0/src/cvode/cvode_ls.c` 的 `cvLsDenseDQJac`。设 `u=SUN_UNIT_ROUNDOFF`、当前误差权重为 `W`、步长为 `h`、状态维数为 `N`,默认前向差分使用: + +```text +fnorm = WRMS(fy, W) +minInc = fnorm != 0 ? 1000 × |h| × u × N × fnorm : 1 +inc[j] = max(sqrt(u) × |y[j]|, minInc / W[j]) +J[i,j] = (1 / inc[j]) × (f_i(t, y + inc[j] e_j) − fy[i]) +``` + +原始源码见 [SUNDIALS v7.4.0 cvode_ls.c](https://github.com/LLNL/sundials/blob/v7.4.0/src/cvode/cvode_ls.c)。候选通过公开接口获取实际 `W` 和 `h`,保留上述扰动公式、原 `inc[j]` 分母以及先求倒数再乘差值的浮点运算顺序,没有把分母改成浮点加法后实际得到的 `y_trial−y`。 + +当前运行时没有向 CVODE 设置 inequality constraints,因此默认差分中的约束翻转规则不触发。若未来添加 `CVodeSetConstraints`,必须同时向着色上下文传入约束并实现同一翻转规则;现有实现不能据此宣称已支持任意约束配置。 + +CVODE 非线性收敛系数的默认值为 `0.1`,它控制非线性迭代停止条件,并不等同于用户的 `rtol`。本轮生产代码没有调整该系数;隔离的 `0.01` 探索见后文。[官方非线性求解器选项](https://sundials.readthedocs.io/en/v7.4.0/cvode/Usage/index.html#c.CVodeSetNonlinConvCoef) + +## 3. 从生成器获得保守结构 + +新增 [jacobian.py](../../app/simulation/native_codegen/jacobian.py),由生成器显式记录当前状态到每个导数的依赖,再计算闭包、列冲突和着色。它处理生成器控制的表达式与既有计算条目,不尝试解析任意外部 C 程序。 + +| 模型 | 状态数 | 保守结构非零数 | 结构密度 | 颜色数 | 路径 | +| --- | ---: | ---: | ---: | ---: | --- | +| 八路 corrected | 132 | 952 | 5.464% | 27 | 可显式启用着色 | +| 四路 corrected | 64 | 468 | 11.426% | 15 | 可显式启用着色 | +| 紧凑 native-skill fixture | 12 | 144 | 100% | 12 | 原默认逐列差分 | + +这里的“非零”是结构上可能非零的位置,包含保守增加的依赖;不是某个采样状态下数值非零项的计数。八路结构哈希为 `a4761c4a71c30eef37f126ec85567ff25efd197728f7f7d1e0e124378a41127a`。构建清单记录结构、策略、颜色、是否具有运行时收益及回退原因,便于复核。 + +生成器将以下依赖纳入闭包: + +- 储气物性对质量、内能、容积的依赖,以及移动气缸容积对机械位置的依赖。 +- 管路、阀口等多输出调用的全部输入,压力和焓的传播,以及局部循环块对外部状态的依赖。 +- 条件判断及各分支的依赖,覆盖正反流和零流量附近的切换。 +- 接触/限位对位置、速度及原导数的联合依赖,以及耦合状态投影和导数重分配。 + +同一颜色中的任意两列不得作用于同一 RHS 行,因此可同时扰动这些列,再按结构位置分别提取导数。漏掉真实依赖会得到错误的雅可比,过度保守则增加颜色数。遇到无法解析的可达输入,整模型回退默认差分;紧凑路径和无合并收益的结构同样使用默认差分。C 启动时还检查 CSC 边界、行索引顺序、颜色范围和同色行冲突。 + +八路当前依赖排序包含 0 个循环块,没有耦合投影组。因此本案例的完整轨迹验证不能替代对新循环模型、新投影组合或新元件的验证。详见 [生成结构审计](../../test/jacobian-20260911/generation-audit.md) 和 [结构汇总](../../test/jacobian-20260911/generated-structure-summary.json)。 + +## 4. 共享物性缓存造成的差分伪耦合 + +直接在原 RHS 上应用 27 色分组的原型,在第 17 次雅可比核对中发现结构外非零项。追踪到的原因是气体状态准备时向共享物性缓存预先登记了已经求出的温度、焓和密度。后续相同参数查询可能取预登记值,也可能从另一条数学等价的物性表达式重新计算;两条路径存在舍入差异。另一支路的扰动改变缓存命中路径后,该差异被很小的差分增量放大,看起来像额外的跨支路依赖。 + +具体探针在 `t=5.5155845013992836e-05 s`:扰动列 80(`PNL0001_17.m`)时,行 69(`PNL0002_7.U`)的旧逐列差分出现约 `3918.1763` 的结构外导数。仅关闭气体预登记后,该探针项精确归零;关闭全部物性缓存的独立探针也得到零。这里证明的是所定位缓存路径的舍入耦合,不能把它解释为物理模型新增了支路连接。[探针结果](../../test/jacobian-20260911/cache-probe/summary.json) + +正式候选采用专用于雅可比的 `model_eval_jacobian`,以下称 canonical RHS: + +1. 普通 `model_eval` 继续使用原有气体物性预登记,积分 RHS 和结果输出保持原求值路径。 +2. canonical RHS 仅关闭气体物性的预登记;以完整显式输入为键的其他物性缓存及管流缓存继续保留。缓存仍在每次系统求值内重新建立,不跨扰动状态复用。 +3. 每次雅可比回调先用 CVODE 传入的普通 `fy` 计算默认差分增量,再额外调用一次 canonical RHS 重算基准值。所有分组扰动、逐列验证和回退都减去这个 canonical 基准。 + +第三步不可省略。若用 canonical 扰动结果减去普通 `fy`,两条求值路径的舍入差异仍会被 `1/inc[j]` 放大。新增 `MODEL_JACOBIAN_CANONICAL_RHS` 宏明确这一策略;未启用该路径的模型不额外计算基准 RHS。 + +这使候选成为**同一数学函数、固定缓存求值路径上的差分近似**。它不再承诺与旧共享缓存路径的全部逐列差分项一致。普通 RHS 不变、雅可比近似改变,也会改变 Newton 迭代、步长接受和最终轨迹;两者必须分别验证。 + +## 5. 回退、取消与计数 + +实现位于 [cvode_solver.c](../../native/runtime/cvode_solver.c) 和 [common.c](../../native/runtime/common.c)。 + +| 情况 | 行为 | +| --- | --- | +| 模型结构不支持、无着色收益,或显式 `dense` | 不安装自定义回调,使用原 CVODE 默认逐列差分 | +| 合并扰动得到可恢复的 RHS 失败 | 从原状态重新逐列计算完整 canonical 矩阵 | +| 单列扰动或基准 RHS 仍可恢复失败 | 向 CVODE 返回失败,交由其恢复流程处理 | +| 取消、超时等不可恢复退出 | 立即返回,不因回退继续增加求值 | +| `verify` 检出任意矩阵项失配 | 使用当次完整 canonical 逐列矩阵,并在本次运行后续及事件重启后关闭分组 | + +`verify` 每次比较全部 `132×132=17,424` 项,包括结构外零项;不是采样子矩阵。实现采用 C 数值精确比较 `!=`,没有设置误差阈值,但不会区分 `+0` 与 `−0`。因此“矩阵零失配”应按此口径理解,不能自动写成带符号零也逐位一致。 + +结果新增 `jacobianMode`、`jacobianRhsCalls`、`jacobianColoredEvals`、`jacobianFallbacks`、`jacobianChecks`、`jacobianMismatches`、`cvodeRhsCalls` 和 `cvodeLinearRhsCalls`。总计数关系为: + +```text +nfev = cvodeRhsCalls + cvodeLinearRhsCalls + jacobianRhsCalls +``` + +自定义回调中的 RHS 由项目自行计入 `jacobianRhsCalls`,其中包括 canonical 基准、验证和失败尝试。SUNDIALS 的 `CVodeGetNumLinRhsEvals` 只统计其内部默认差分调用,不会代记用户回调的 RHS。[官方计数接口](https://sundials.readthedocs.io/en/v7.4.0/cvode/Usage/index.html#c.CVodeGetNumLinRhsEvals) 各 CVODE 实例/重启区间释放前累计库计数,项目计数直接跨区间累加,不能把这些包含关系重复相加。 + +回归中还发现了独立的取消竞态:准备阶段已经收到取消请求时,快速原生进程可能在 Python 监控首次轮询前完成。[runner.py](../../app/simulation/native_codegen/runner.py) 现于启动进程前检查取消标记并提前写出取消文件,保留部分接受状态及公共任务状态合同。该修复没有改变数值公式。 + +## 6. 已完成验证及其覆盖范围 + +### 6.1 默认公式的独立校验 + +[test_native_jacobian_runtime.py](../../tests/test_native_jacobian_runtime.py) 的 8 项小型 C harness 测试已通过。测试直接调用本地 SUNDIALS 7.4.0 库的 `cvLsDenseDQJac` 作为独立默认算法 oracle;私有头文件仅用于测试,不进入生产编译依赖。 + +覆盖 25 组状态、权重和步长组合,包括零 RHS 范数、正负步长、异量级分量,以及“实际舍入后的扰动不等于原增量”的情形。未启用 canonical 路径的矩阵与实际库默认差分按位比较。另覆盖结构边界、合并扰动失败后的完整回退、逐列失败、取消、失配后跨重启禁用分组和多段事件计数。 + +canonical 专项故意令普通 `fy` 与 canonical 基准不同,证明:增量仍来自普通 `fy`,差分基准确实重新求值,普通输入向量未被改写,所有基准/探针调用均进入计数。该专项不把 canonical 矩阵冒称为旧缓存默认矩阵。 + +### 6.2 结构与普通 RHS 保持 + +[test_native_jacobian_structure.py](../../tests/test_native_jacobian_structure.py) 已完成 8 项结构测试,覆盖 50 个冻结目录组合的结构、未知输入关闭优化、循环闭包、投影与限位、紧凑路径回退,以及固定八路状态的完整 canonical 矩阵比较。 + +另以旧保留共享库和候选程序比较同一时间点的 5 组固定状态,每组检查 132 个 RHS 值和 1,784 个输出值,共 9,580 个 binary64 数值,全部逐位一致。该证据支持普通求值路径保持,不是完整积分轨迹逐位相同的声明。[普通 RHS 位比较记录](../../test/jacobian-20260911/ordinary-rhs-bit-parity.json) + +### 6.3 完整八路初测 + +下表为 `canonical-jac-smoke` 中 0–10 s 的单次运行,所有模式均正常完成。它用于记录调用关系和验证行为,**不是正式性能验收中位数**。 + +| 指标 | 默认 dense | 候选 auto | 诊断 verify | +| --- | ---: | ---: | ---: | +| 求解墙钟时间 / s | 6.049942 | 2.575630 | 9.236601 | +| `nfev` | 74,265 | 22,853 | 80,009 | +| `cvodeRhsCalls` | 11,565 | 10,729 | 10,729 | +| `cvodeLinearRhsCalls` | 62,700 | 0 | 0 | +| `jacobianRhsCalls` | 0 | 12,124 | 69,280 | +| `njev` | 475 | 433 | 433 | +| 接受步数 | 6,974 | 6,660 | 6,660 | +| 拒绝步数 | 454 | 359 | 359 | +| `nlu` | 1,656 | 1,404 | 1,404 | +| 状态转换 / solver starts | 1 / 4 | 1 / 4 | 1 / 4 | +| 分组核对次数 / 失配 | 不适用 | 未启用核对 | 433 / 0 | +| 分组回退次数 | 0 | 0 | 0 | + +候选的 `12,124=433×(27+1)`,其中每次额外的 1 是 canonical 基准。诊断的 `69,280=433×(27+1+132)`,每次还计算完整逐列矩阵。`verify` 的额外工作不应混入生产候选性能。 + +433 次雅可比的全矩阵核对均无失配,支持该完整轨迹上分组计算与 canonical 逐列计算一致;两种模式的输出/最终状态比较也一致。默认 dense 与候选的 `njev` 和接受步数已经变化,说明不能把总调用下降全部归因于固定次数的 `132→27` 替换,更不能要求候选轨迹和基线逐位相同。 + +证据目录:[完整八路初测](../../test/jacobian-20260911/canonical-jac-smoke/),[初始轨迹差异](../../test/jacobian-20260911/canonical-jac-smoke/initial-differences.json)。 + +### 6.4 回归与取消专项 + +首轮后端回归记录为 40 项、39 通过、1 项取消状态竞态失败;不能把该原始日志写成全部通过。修复预启动取消后,原生部分结果、raw stream 取消和任务 stop 的 3 项专项复测通过。[首轮日志](../../test/jacobian-20260911/regression.log)、[取消专项复测](../../test/jacobian-20260911/cancellation-regression.log) + +随后在最终默认 `dense` / 环境开关代码上重跑完整 6 项结果传输测试和 2 项原生/任务停止测试,8 项均通过(7.037 s)。命令与 [最终日志](../../test/jacobian-20260911/transport-final.log): + +```sh +.venv/bin/python -m unittest tests.test_native_result_transport tests.test_native_codegen.NativeExecutionTests.test_cancellation_returns_partial_accepted_state tests.test_generic_system_xml_simulation.GenericSystemXmlSimulationTests.test_streaming_task_stop_returns_partial_result -v +``` + +首轮通过的 39 项包括默认 `dense` 的四路 72 条 AME 曲线回归,沿用压力 250 Pa、温度 0.015 K、位移 2e-6 m、速度 1e-5 m/s、质量流量 3e-5 kg/s 的原门槛。本轮没有扩大门槛;这不能代替八路试验版的外部曲线验收。修复后只复测受影响的传输/取消路径,没有重复无关的完整求解。 + +## 7. 精度边界与保留默认 dense 的原因 + +雅可比逐项核对验证的是差分合并是否正确,不是积分全局误差。相同 `rtol` 控制局部误差,并不要求不同雅可比近似产生相同接受步、事件插值或整个轨迹。[CVODE 容差选择说明](https://sundials.readthedocs.io/en/v7.4.0/cvode/Usage/index.html#tolerance-selection) + +以 `dense, rtol=1e-10` 的较严格结果为数值参考,初步交叉比较发现:默认 `dense, rtol=1e-8` 的最大温差约 `0.046797 K`,候选 `auto, rtol=1e-8` 约 `0.054348 K`。压力及多数机械状态指标改善,温度局部指标略退。更严格容差的交叉比较支持收敛趋势,但这一参考本身仍是数值解,不能当作解析真值,也不足以把候选称为等精度替换。 + +为检查非线性停止条件的影响,另做了 `NonlinConvCoef=0.01` 的隔离原型。该次运行约 `7.905 s`、1,907 次雅可比构造,明显增加工作量;没有纳入生产代码,也没有与普通候选正式性能混合。现有生产仍沿用默认系数 `0.1`。 + +以下比较全部 1,784 条输出的 1,001 个精确共同普通采样时间,不跨事件插值;额外事件点单列。各组为相同容差下 `dense` 与 `auto` 的最大绝对差: + +| 量 | `rtol=1e-8` | `1e-9` | `1e-10` | +| --- | ---: | ---: | ---: | +| 压力 / Pa | 19.32135 | 1.42791 | 0.059363 | +| 温度 / K | 0.0228371 | 0.00130767 | 0.000245508 | +| 气体质量 / kg | 1.26604e-8 | 1.07764e-9 | 2.53289e-11 | +| 气体内能 / J | 0.0194956 | 0.000800470 | 2.97881e-5 | +| 限位机械 1~8/10 速度 / m/s | 3.84792e-8 | 2.31905e-9 | 5.56235e-10 | +| 限位机械 1~8/10 位移 / m | 3.84793e-8 | 1.52101e-9 | 1.36450e-10 | +| 事件时间 / s | 8.86875e-9 | 4.34638e-10 | 4.89192e-11 | +| 极端机械 9 位移 / m | 48,794,961 | 156 | 118 | + +相对 `dense, 1e-10` 的参考,旧/新默认温度组最大误差为 **0.0467973 → 0.0543480 K(+16.13%)**,汇总 RMS 为 **0.0111825 → 0.0131265 K(+17.39%)**。56 条唯一温度曲线中有 15 条的最大误差和 RMS 同时增加。`PNL0002_4/8.T` 在 0.06 s 附近参考约 4368.8398 K,最大误差从约 0.040975 K 增到 0.054348 K。两份 `1e-10` 参考之间最大温差仅 0.000245508 K,该退化不能用参考策略之间的小差异消除。 + +相对同一参考,压力组最大误差 91.2276 → 87.2302 Pa,质量/内能整体指标、限位机械 1~8/10 的全部 9 条速度和 9 条位移曲线均改善。整体改善仍不代表每条压力或能量曲线都改善。上述局部容差只用于诊断,不被解释成轨迹全局误差上界。 + +事件与异常保留如下: + +- 六份结果均有 1 次状态跳变、4 次求解器启动。旧/新默认额外事件时间为 0.9833956321732664 / 0.9833956233045202 s;机械 10 到达 0.37 m 后速度清零。事件峰力约 4.035e11 N,前后差约 2345 N(相对 5.81e-9),没有用普通采样点掩盖该窄峰。 +- 56 个唯一气体质量状态使用 `math.fsum` 累加;六份全采样总质量最大漂移为 7.99e-15~2.31e-14 kg。守恒不能替代每条曲线的精度检查。 +- 机械 9 的原模型含 `UD00=1e17` 极端信号,位移可达约 7.68e15 m。默认前后 4.8795e7 m 的最大位移差相对约 1.09e-8,直接来自连续状态,不能归为辅助输出舍入;这类巨大绝对数值和 AME 差异在 [此前八路核查](test-mql-8当前AME归档与完整曲线核查-2026-09-11.md) 已存在。两份紧容差参考之间机械 9 速度仍可差 26.25 m/s,暂不对其精度排序。 +- 近零流的摩擦因子和节点返回焓仍敏感。`PNL00R_5` 的流量在 3.41 s 从约 -6.09e-6 变为 -1.93e-22 kg/s,摩擦因子从约 2.26 变为 6.4e7;`P4NODE2_6.port_2` 在 5.82 s 流量跨过零和混合正则化阈值,返回焓差约 1.4e8 J/kg。两份 `1e-10` 之间返回焓最大差仍约 1.91e7 J/kg,不能把全部辅助输出判为已充分收敛。 + +完整逐曲线、极值时刻、总质量、事件和最终状态记录见 [轨迹与收敛审阅](../../test/jacobian-20260911/convergence-review.md)、[可复现汇总](../../test/jacobian-20260911/convergence/convergence-summary.json);使用 [compare_jacobian_trajectories.py](../../tests/manual/compare_jacobian_trajectories.py) 保留来源 SHA 和每条输出差异。 + +本轮据此作为显式启用的试验功能交付,默认继续 `dense`。对新元件、新结构和不同工况,结构声明仍需独立审查;`auto` 并不会在每次运行中自动做完整逐列核对,只有 `verify` 提供该诊断。 + +本报告不构成八路 AMESim 物理验收。较严格 native 数值结果、canonical 矩阵 oracle、普通 RHS 位比较和外部 AMESim 曲线分别回答不同问题;既有四路 AMESim 回归门槛也不能直接作为八路已验收的证据。 + +## 8. 使用方式 + +原生程序默认等同 `--jacobian dense`。下面的 `path/to/model` 替换为本轮生成的八路可执行文件;显式参数避免混用原生 CLI 的其他默认值。 + +```sh +path/to/model --method BDF --start 0 --stop 10 --sample-step 0.01 --max-step 1e30 --rtol 1e-8 --jacobian dense --output dense.json +path/to/model --method BDF --start 0 --stop 10 --sample-step 0.01 --max-step 1e30 --rtol 1e-8 --jacobian auto --output auto.json +path/to/model --method BDF --start 0 --stop 10 --sample-step 0.01 --max-step 1e30 --rtol 1e-8 --jacobian verify --output verify.json +``` + +`dense` 保留原默认差分;`auto` 在结构受支持且有收益时启用分组,否则回退默认差分;`verify` 在支持分组时额外逐次核对完整 canonical 矩阵。RK45 不使用这些雅可比路径,结果标记为 `not-used`。 + +Python runner 接受环境变量 `SIMULATION_NATIVE_JACOBIAN=dense|auto|verify`,默认 `dense`。独立预览服务启动前设置 `auto` 即可试用;该设置作用于该服务进程,不写入模型 JSON。环境变量值非法会报错,不能静默猜测模式。 + +## 9. 正式原生与网页对照 + +原生正式对照由 [benchmark_native_jacobian.py](../../tests/manual/benchmark_native_jacobian.py) 在同一可执行文件上显式切换 `dense/auto`,每组 1 次预热 + 3 次正式运行,配对顺序交替且全部串行。`verify` 单独运行,不进入速度统计。Linux x86_64、GCC 13.3、SUNDIALS 7.4.0,模型、容差、编译浮点选项及输出不变。 + +```sh +.venv/bin/python tests/manual/benchmark_native_jacobian.py --input tests/data/test-mql-8-corrected.json --output-dir test/jacobian-20260911/benchmark --warmups 1 --repeats 3 --verify --record-differences --run +``` + +构建键 `9f38485cdedb422c74226311dfdd3838036f39bc9e238b4358e46275a1bedab4`,首次构建单次 3.903 s。完整命令、来源 SHA、配置和计数保存在 [正式原生 summary](../../test/jacobian-20260911/benchmark/summary.json)。最后只补充 manifest 中默认 `dense` / 显式启用的说明字段,八路与 compact 的 `model.c/h` 前后 SHA 完全一致;[检查记录](../../test/jacobian-20260911/default-policy-metadata-check.json) 解释了说明字段改变可能造成的缓存键更新。 + +`--record-differences` 的含义是保留不等价结果并完成计时,**不是放宽验收**。本次 `complete=true`、`passed=false`、`allPayloadBitsEqual=false`:后两者如实记录 `dense/auto` 轨迹及事件时间不同。相同模式重复运行保持数值结果一致;`auto/verify` 的完整输出和最终状态一致,433 次矩阵核对零失配。第 7 节单独评估这些差异。 + +| 正式原生指标 / s | dense 中位 | auto 中位 | 两组中位数之比:减少 | +| --- | ---: | ---: | ---: | +| C 求解墙钟 | 5.791500 | 2.492559 | 56.96% | +| C 求解 CPU | 5.790168 | 2.481718 | 57.14% | +| 子进程完整墙钟(含结果写出) | 6.091389 | 2.827177 | 53.59% | + +三次求解墙钟逐对降幅为 56.80%、57.12%、56.96%,其中位数 56.96%;上表使用组中位数之比。完整进程逐对降幅中位为 53.88%,与组中位数之比 53.59% 的口径不同。正式计数与第 6.3 节一致:RHS 总数 74,265 → 22,853(-69.23%),雅可比差分 RHS 62,700 → 12,124(-80.66%),`njev` 475 → 433,接受步 6,974 → 6,660,误差检验拒绝步 454 → 359。 + +### 9.1 正式网页端到端 + +基线网页服务使用 `git archive 3bc4be3` 冻结的后端源码,候选服务显式启用 `auto`。同一份生产 `frontend/dist` 分别复制后提供服务,实际加载的主线程及 worker 资源集合 SHA 均为 `f089bfc67e38ab5a4a0f7649d94f711642d4ab08b690be50b8ef075d8422a1c2`。两版各运行一组 control 和一组 profiled;每组 1 次预热 + 3 次正式,16 次全部串行,未与其他求解/大结果分析并行。测试使用真实 Chromium 和真实 HTTP,并完成模型导入、运行、查看曲线、保存、下载 CSV/结果文件及刷新恢复。 + +每次导入后通过公开导出检查元件、参数、端口连接。浏览器及 CLI 的 XML 字节序列化不同;9 份 XML 经同一生成器得到完全相同的 C 源码和头文件,[核对记录](../../test/jacobian-20260911/xml-generated-program-parity.json)。未把不同 XML SHA 冒称为相同字节,也未为提速改变模型。 + +以下为**未插桩 control** 三次中位;范围保存在便携摘要中。 + +| 用户过程 | 基线 / s | 试验 / s | 变化 | +| --- | ---: | ---: | ---: | +| 点击运行 → 结果可查看(就绪 DOM) | 7.0185 | 3.4556 | 减少 50.76% | +| 点击运行 → 就绪后绘制机会(两帧) | 7.0800 | 3.4692 | 减少 51.00% | +| 点击运行 → 浏览器保存完成(观察值) | 7.1597 | 3.5793 | 减少 50.01% | +| 点击 CSV → 下载保存完成 | 1.0173 | 1.1193 | 增加 10.03% | +| 点击结果文件 → 下载保存完成 | 1.3590 | 1.2876 | 减少 5.25% | +| 刷新 → 已保存结果 DOM 可见 | 0.2721 | 0.2673 | 接近 | + +![原生与网页正式中位数](assets/2026-09-11/jacobian-time-comparison.svg) + +CSV 的三次范围为旧 0.9066~1.2685 s、新 1.0386~1.2845 s,重叠明显;本轮未修改前端,不能把这一小样本差异解释成稳定的导出退化或优化。它独立于本轮求解速度收益,不宣称改善。浏览器“保存完成”指 IndexedDB 持久化的提交及指针可见;control 通过最多约 16 ms 间隔轮询观察,另有调度延迟。CSV/结果文件下载完成包含浏览器通知与自动化 `saveAs` 的本地文件成本。两帧是绘制机会,不是 GPU 完成证明。 + +**冷编译单列:** control 首轮基线编译 3.8785 s、点击到可查看 10.9099 s;候选编译 3.9581 s、点击到可查看 7.4655 s。每种只有一次,不是冷启动中位数;正式三轮全部缓存命中。候选额外生成保守结构,在已命中缓存的 profiled 请求中 C 生成中位从约 62.8 增至 93.0 ms,已算入端到端结果。 + +### 9.2 前后端阶段 + +下面来自独立 profiled 组,用于定位成本。各时间存在嵌套或异步重叠,不能相加;这些组的绝对耗时不替代 9.1 的 control。插桩组就绪时间相对 control 的组中位差为基线 -2.02%、候选 +2.36%,同时含组间调度和缓存波动,不能视为纯插桩开销。 + +| 阶段 / ms | 基线中位 | 试验中位 | +| --- | ---: | ---: | +| 点击到发起请求(校验/快照/XML 等) | 18.800 | 22.100 | +| 结果 JSON.parse | 108.700 | 103.400 | +| 流式 TextDecoder 同步工作 | 30.600 | 28.000 | +| parse 结束到结果就绪 DOM(含调度) | 56.700 | 38.500 | +| 结果页签到 DOM 可见 | 119.500 | 126.400 | +| 曲线选择到 DOM 可见 | 21.200 | 19.600 | +| CSV 点击到下载锚点 | 487.100 | 506.600 | +| CSV worker 启动到完成消息(含重叠工作) | 459.700 | 476.700 | + +就绪 DOM 后到 IndexedDB 全部提交的逐次差值中位为 98.7 → 107.7 ms,仍含异步调度,不能当作纯存储 CPU。 + +| 阶段 / ms | 基线中位 | 试验中位 | +| --- | ---: | ---: | +| XML 校验 | 23.138 | 22.740 | +| 网络编译 | 40.268 | 38.930 | +| C 生成与结构分析 | 62.825 | 92.987 | +| 编译缓存校验(命中) | 34.641 | 34.772 | +| C 积分 | 6022.966 | 2662.376 | +| C 输出投影 | 80.622 | 80.972 | +| C 编码与写出 | 166.097 | 167.640 | +| Python 索引结果读取(含原始字节) | 41.523 | 45.054 | +| 其中小型元数据 JSON.parse | 1.155 | 1.342 | +| 响应 JSON 元数据编码/拼装 | 32.837 | 24.171 | +| ASGI send 等待窗口(非纯网络) | 48.446 | 53.691 | +| 整个后端 HTTP 请求 | 6582.457 | 3293.631 | + +候选 C 输出投影 CPU 中位 0.080972 s、编码写出 CPU 0.167599 s,与对应墙钟接近。HTTP 读等待包含后端生成、传输和浏览器调度,不将其整体归为网络;解析后的 DOM 等待也不整体归为绘图。Ryu/批量写出、原始数值片段直传、Float64 保存和 worker CSV 均沿用已同步基线;本轮主要减少积分等待。 + +### 9.3 完整数值传输核对及复现 + +计时结束后再解析大结果。旧网页两组共 8 次与正式 native `dense` 比较,新网页两组 8 次与 native `auto` 比较:每次 1,002 点、1,784 条输出、1,788,570 个含时间列的 series/CSV 数值及 1,784 个 final 值全部一致。总计核对 **28,617,120 个 CSV 单元**;每组刷新恢复后的完整结果保持不变。旧版网页对新代码 `dense` 的一致性,还独立支持默认路径没有改变。比较使用解析数字 `==`,不区分正负零;它不是旧版与试验版轨迹相等的声明。[旧组核对](../../test/jacobian-20260911/equality-baseline.json)、[新组核对](../../test/jacobian-20260911/equality-optimized.json) + +轻量结果随文档保留在 [便携成本摘要](assets/2026-09-11/jacobian-cost-summary.json),包含 n/min/median/max、缓存状态、来源 SHA 和关键计数。完整细节在 Git 忽略的 [cost-summary.json](../../test/jacobian-20260911/cost-summary.json),由 [summarize_jacobian_cost.py](../../tests/manual/summarize_jacobian_cost.py) 只读取小型 metadata 重建: + +```sh +.venv/bin/python tests/manual/summarize_jacobian_cost.py --root test/jacobian-20260911 +``` + +网页原始目录为 `test/jacobian-20260911/browser-{baseline,optimized,baseline-profiled,optimized-profiled}/`。每个 profiled 请求按 `simulationId` 关联后端 `requests//stages.json`,保存实际命令、C 分阶段时钟及 ASGI 时序,避免错配不同运行。 + + +## 10. 新版积分成本与后续优化 + +使用同一正式原生缓存和相同运行参数,在隔离源码副本中加嵌套时钟;control 和 profiled 各预热 1 次、正式 3 次,串行配对。8 次运行的完整 `series/final/finalState` 和求解计数精确相等(仅排除两个求解计时字段)。计数核对同时包含项目自计的 canonical RHS,不把它遗漏在 CVODE 库的计数之外。[诊断记录](../../test/jacobian-20260911/native-compute-profile/summary.json) + +| 新版积分阶段 | 排他墙钟中位 / s | +| --- | ---: | +| 系统 RHS 求值 | 2.161145 | +| Dense 矩阵分解 | 0.153113 | +| Dense 线性回代 | 0.102039 | +| CVODE 其余内部工作 | 0.050597 | +| 雅可比分组/填充自身(扣除 RHS/轮询) | 0.007622 | +| 轮询 | 0.002934 | +| 积分外层、接受处理、采样与插值 | 分别约 0.001549 / 0.001366 / 0.000783 / 0.000280 | + +RHS 约占同次积分的 87%;完整雅可比回调含 RHS 的中位时间 1.326561 s,约 53%;二者嵌套,不能相加。矩阵分解与回代约占 10%。正式控制组中位 2.438663 s,插桩组 2.481265 s,两组中位数之比开销约 1.75%;这些诊断时间不代替前述无插桩基准。表中的各列中位数也不应相加冒充某次运行总数。 + +CVODE 常规 RHS 10,729、非线性迭代 10,721、非线性收敛失败 365。此前基线为 11,565 / 11,557 / 414,收敛失败减少约 11.84%;这不是管路局部求根耗尽迭代的次数,也不能把误差检验拒绝步 359 混称为同一指标。本轮管流局部算法没有改动,未重新记录其逐次迭代直方图。 + +下一步先处理两项:一是稳定近零流和共享物性求值路径带来的导数/精度敏感性,给八路建立明确的全局曲线精度目标;二是在可靠依赖结构上尝试组件局部导数、局部扰动求值或部分解析雅可比,减少仍占约一半积分成本的雅可比构造。单独替换稀疏 LU 的收益受到当前约 10% 代数占比的限制,不能根据矩阵稀疏率直接预测总加速。 + +## 11. 交付状态 + +先前结果处理优化及报告已提交并推送到 `origin/system-optimization`,提交 `3bc4be3`。本轮雅可比试验代码、测试和报告保留为该基线之上的本地改动,默认仍为 `dense`;独立试验预览为 `http://127.0.0.1:8030`,其服务显式设置 `SIMULATION_NATIVE_JACOBIAN=auto`。若重启该服务,请沿用第 8 节的环境设置。 + +没有新增运行依赖。现有 Python 和 SUNDIALS 位于 `.venv/` 与 `.venv/native/`,浏览器辅助环境位于忽略的 `.tools/` / `.venv/native/`;所有大结果、下载、缓存、冻结基线和临时原型保存在 Git 忽略的 `test/jacobian-20260911/`。源代码、轻量回归 fixture 和文档可审阅,环境及大结果未上传。 + +该版本完成八路运行、性能测量、矩阵构造验证与端到端结果处理核对;温度局部误差退化和近零辅助量敏感性仍公开保留,不标记为等精度生产默认替换或八路 AMESim 曲线验收。没有可比较的可信 AMESim 运行耗时,外部速度对比继续跳过。 diff --git a/docs/update-log/更新日志-2026-09-11.md b/docs/update-log/更新日志-2026-09-11.md index a2cb056..d647aba 100644 --- a/docs/update-log/更新日志-2026-09-11.md +++ b/docs/update-log/更新日志-2026-09-11.md @@ -1,5 +1,9 @@ # 2026-09-11:C 管路验证版合入网页后端 +用户确认接受雅可比试验误差后,已将结构着色差分设为正式默认并重启正常 8000/5173 服务。删除旧算法环境变量/CLI 选择器,只保留 `--verify-jacobian` 诊断及必要自动回退。57 项回归通过(含四路原 AME 门槛),正式八路三轮中位求解 2.6066 s、结果可查看 3.4985 s、浏览器保存 3.6134 s;5 次网页运行、下载与刷新恢复和已接受候选一致。当前状态及使用方式见 [正式启用报告](../other/雅可比算法正式启用与网页验收-2026-09-11.md),下文“试验版/默认 dense”为启用前历史记录。 + +雅可比结构着色试验完成:先前结果处理优化已通过 `3bc4be3` 推送到 `origin/system-optimization`,其后新增显式启用的 `auto/verify` 候选,默认继续 `dense`。修正八路 132 状态合并为 27 色,每次另算 1 次 canonical 基准;原生三轮中位 5.7915→2.4926 s(-56.96%),网页可查看 7.0185→3.4556 s(-50.76%),浏览器保存 7.1597→3.5793 s(-50.01%)。433 次全矩阵核对零失配、16 次网页/CSV/恢复完整核对通过,取消竞态已修复并复测。部分温度相对较严格参考的误差略增,未宣称等精度或八路 AME 验收;本轮试验改动保留本地。详见 [雅可比试验与阶段成本报告](../other/雅可比结构着色试验与八路验证-2026-09-11.md)。 + 同步基线记录:将已验证的原生结果直传、浏览器Float64缓存与CSV工作线程、C端Ryu编码写出,以及八路性能调研/复现工具统一保存到 `system-optimization`。本次同步包含源代码、测试和报告;本地环境、仿真大结果和临时构建继续保留在Git忽略目录。雅可比矩阵优化从这份基线之后单独开展,沿用修正八路与 `rtol=1e-8`。 C结果编码写出优化完成:采用精确回读的Ryu数字编码与64 KiB批量写出,保留JSON结构、全量采样及binary64数值。相同修正八路,正式运行各三次,C写出1.1808→0.1638 s(-86.13%),点击到可查看8.0100→6.9756 s(-12.91%),点击到缓存保存8.1280→7.0841 s(-12.84%);CSV1.1127→1.1506 s,未观察到改善。冷编译单次3.658→4.372 s,首次网页11.682→11.381 s,已单独披露。10项编码专项、29项相关回归、8份原生结果逐位与16次网页/CSV/恢复核验通过。研究、限制、分阶段数据和复现见 [C端编码与写出报告](../other/C端结果编码与写出优化-2026-09-11.md)。未安装新环境或提交Git。 diff --git a/native/README.md b/native/README.md index 8085970..5fdbbad 100644 --- a/native/README.md +++ b/native/README.md @@ -48,13 +48,21 @@ EXE 不需要 Python、SciPy、XML 或原工程文件。DLL 需要与 EXE 一同 `--solve-only` 关闭轨迹采样,只输出最终状态与诊断。`solveSeconds` 是程序内部数值求解墙钟时间,包含求解必需的 RHS 和事件定位,排除模型初始化、结果投影和文件写入;`processWallSeconds` 另含进程启动与结果处理。预热一次后报告三次求解的中位数。 +## 雅可比分组差分与诊断 + +BDF 默认使用结构着色差分,无需环境变量或网页选项。修正八路的 132 个状态合并为 27 个扰动组,每次另算 1 次专用基准 RHS,共 28 次系统求值。普通积分 RHS、`rtol=1e-8` 和 Dense LU 沿用当前设置。用户已接受报告中的数值差异,正式网页与独立 C 程序采用同一默认策略。 + +旧 `SIMULATION_NATIVE_JACOBIAN` 选择器和 `--jacobian dense|auto|verify` 入口已删除。独立 C 程序仅保留无参数诊断标志 `--verify-jacobian`,它会逐次核对完整 canonical 雅可比矩阵,增加计算量,不用于速度测量。RK45 不构造雅可比。 + +无法证明结构、没有分组收益的模型继续使用必要的逐列差分;分组扰动失败时也保留 canonical 逐列恢复。这些是当前算法的兼容与恢复路径。当前启用及网页验证见 [正式启用报告](../docs/other/雅可比算法正式启用与网页验收-2026-09-11.md),机制、历史性能与误差见 [试验报告](../docs/other/雅可比结构着色试验与八路验证-2026-09-11.md)。 + ## 能力与限制 - 已实现当前注册的 27 类组件:22 类 Amesim 公开组件(含空气、氦气两种介质定义)和 5 类实验组件。完整清单及验证说明见 [组件覆盖记录](../docs/other/C内核组件库覆盖记录.md)。介质定义在编译期选择对应的 C 物性函数。 - 管路覆盖 PNL00R、PNL0001/2/3;阀覆盖 PNOR001、固定/信号开度 PNVO001 及面积/Cv/Kv 模式;连接件覆盖 PN3NODE2、P4NODE2、LMECHN1。支持串联阻力的压力求解、节点焓混合及温度参考、刚性质量合并、兼容管路容腔的等密度状态投影。 - MECMAS21 支持现有 Python 方程中的摩擦、风阻、柔性限位和 `stoptype=1/2/3/4`,含反弹系数与速度阈值;气腔和管路支持换热。LSTP00A 接受两种刚度模式及接触力符号模式,严格沿用当前组件方程。已有参数中尚未参与 Python 方程的物理效应不会在 C 端凭空补造,详见覆盖记录。 - 扩展编译器上限 1024 状态、16384 输出;无连续状态的信号系统使用隐藏常量状态驱动输出。气动网络必须有压力状态锚点;独立气腔之间不能无阻力直接相连。兼容固定管路容腔是已实现的合并例外。闭合未收敛或方程欠定时明确失败,不静默回退。 -- 支持原生 RK45 与 CVODE BDF。CVODE 继续使用默认数值雅可比和稠密线性求解;本次删除不改变 C 求解器的雅可比策略。 +- 支持原生 RK45 与 CVODE BDF。CVODE 默认按可证明的结构启用着色差分,使用稠密线性求解;不支持分组的模型自动保留逐列差分。 - 网页和 Python CLI 默认 `rtol=1e-8`;生成的状态绝对误差限为质量 `1e-14 kg`、内能 `1e-8 J`、速度/位移 `1e-12`(各自 SI 单位)。独立 C 程序默认 `rtol=1e-6`,对照时应显式传入。CLI 可覆盖 rtol;本版不支持自定义 atol 或 first_step。不同积分器相同局部容差不保证全局曲线误差完全相同。 - 时间信号显式分段,塑性/反弹端挡用稠密插值定位并重启。试探 RHS 不修改已接受状态。柔性接触沿用现有分段力公式,不改变刚度或阻尼来提速。 - 每任务独立进程,支持进度、取消及超时。进程崩溃不会作为成功返回,受控失败保留最后接受状态。 diff --git a/native/include/runtime.h b/native/include/runtime.h index 23417e0..0f2d10d 100644 --- a/native/include/runtime.h +++ b/native/include/runtime.h @@ -15,6 +15,10 @@ typedef struct { double wall_start, cpu_start, solve_seconds, solve_cpu_seconds, last_progress; double max_accepted_step; unsigned long nfev, accepted, rejected, events, starts, njev, nlu; + /* Structure coloring is automatic; verification is diagnostic only. */ + int jacobian_verify, jacobian_colored; + unsigned long jacobian_rhs, jacobian_colored_evals, jacobian_fallbacks; + unsigned long jacobian_checks, jacobian_mismatches, cvode_rhs, linear_rhs; int status; /* 0 completed, 1 cancelled, 2 failed */ const char *message; } NativeRun; @@ -23,6 +27,7 @@ double native_wall_time(void); double native_cpu_time(void); int native_poll(NativeRun *run, double time); int native_rhs(NativeRun *run, double t, const double *y, double *dy); +int native_jacobian_rhs(NativeRun *run, double t, const double *y, double *dy); int native_append(NativeRun *run, double t, const double *y); int native_accept(NativeRun *run, double t, double next, const double *old, const double *trial, NativeDense dense, void *context, diff --git a/native/runtime/common.c b/native/runtime/common.c index f5b3dcd..998ecc2 100644 --- a/native/runtime/common.c +++ b/native/runtime/common.c @@ -54,6 +54,19 @@ int native_rhs(NativeRun *r, double t, const double *y, double *dy) { return model_eval(t,y,dy,w); } +/* Derivative probes use deterministic property evaluation: the generated + * Jacobian entry point omits cross-storage cache seeds, while the ordinary RHS + * retains all existing physical expressions and state-property reuse. */ +int native_jacobian_rhs(NativeRun *r, double t, const double *y, double *dy) { +#if defined(MODEL_JACOBIAN_CANONICAL_RHS) && MODEL_JACOBIAN_CANONICAL_RHS + double w[NOUTPUTS]; + r->nfev++; + return model_eval_jacobian(t,y,dy,w); +#else + return native_rhs(r,t,y,dy); +#endif +} + int native_append(NativeRun *r, double t, const double *y) { r->final_time=t; memcpy(r->final_state,y,NSTATES*sizeof(double)); if (!r->options.record_samples) return 1; diff --git a/native/runtime/cvode_solver.c b/native/runtime/cvode_solver.c index 28486d8..b28a858 100644 --- a/native/runtime/cvode_solver.c +++ b/native/runtime/cvode_solver.c @@ -1,5 +1,7 @@ -/* CVODE owns its default numerical Jacobian and dense linear solver. - * No project Jacobian, sparsity or derivative policy is changed here. +/* CVODE retains its Jacobian refresh policy and dense matrix/LU solver. + * A compiler-proven sparsity pattern groups independent finite differences; + * unsupported models and patterns without grouping benefit retain CVODE's + * default callback. */ #include "runtime.h" #include @@ -9,6 +11,10 @@ #include #include #include +#include +#ifndef MODEL_JACOBIAN_COLORED +#define MODEL_JACOBIAN_COLORED 0 +#endif typedef struct { void *solver; N_Vector scratch; } CvDense; static int cv_dense(void *context, double t, double *out) { @@ -17,16 +23,176 @@ static int cv_dense(void *context, double t, double *out) { memcpy(out,N_VGetArrayPointer(d->scratch),NSTATES*sizeof(double)); return 1; } +typedef struct { + NativeRun *run; + void *solver; + N_Vector weights; + SUNMatrix reference; + int colored; +} CvContext; static int cv_rhs(sunrealtype t, N_Vector y, N_Vector dy, void *context) { - NativeRun *r=context; + NativeRun *r=((CvContext *)context)->run; if (!native_poll(r,r->final_time)) return -1; return native_rhs(r,t,N_VGetArrayPointer(y),N_VGetArrayPointer(dy)) ? 0 : 1; } + +#if MODEL_JACOBIAN_COLORED +/* Check the generated CSC bounds and coloring once, before installing it. */ +static int valid_coloring(void) { + if (MODEL_JACOBIAN_COLOR_COUNT<=0 || MODEL_JACOBIAN_COLOR_COUNT>=NSTATES || + model_jacobian_col_ptr[0]!=0 || model_jacobian_col_ptr[NSTATES]!=MODEL_JACOBIAN_NNZ) return 0; + for (int j=0;j=MODEL_JACOBIAN_COLOR_COUNT || + model_jacobian_col_ptr[j]<0 || model_jacobian_col_ptr[j+1]MODEL_JACOBIAN_NNZ) return 0; + int previous=-1; + for (int k=model_jacobian_col_ptr[j];k=NSTATES) return 0; + previous=row; + } + } + for (int color=0;colorrun,context->run->final_time)) return -1; + context->run->jacobian_rhs++; +#if defined(MODEL_JACOBIAN_CANONICAL_RHS) && MODEL_JACOBIAN_CANONICAL_RHS + return native_jacobian_rhs(context->run,t,N_VGetArrayPointer(y),N_VGetArrayPointer(f)) ? 0 : 1; +#else + return native_rhs(context->run,t,N_VGetArrayPointer(y),N_VGetArrayPointer(f)) ? 0 : 1; +#endif +} + +/* Same perturbation and reciprocal-multiply order as SUNDIALS 7.4 dense DQ. + * No constraints are set by this runtime. If that changes, carry their vector + * into this context and apply CVODE's sign rule before enabling coloring. */ +static int jac_increments(CvContext *context, N_Vector y, N_Vector fy, double *increments) { + sunrealtype step; + if (CVodeGetErrWeights(context->solver,context->weights)<0 || + CVodeGetCurrentStep(context->solver,&step)<0) return -1; + double norm=N_VWrmsNorm(fy,context->weights); + double minimum=norm!=0 ? 1000.0*fabs(step)*SUN_UNIT_ROUNDOFF*NSTATES*norm : 1.0; + double *state=N_VGetArrayPointer(y), *weight=N_VGetArrayPointer(context->weights); + double square_root=sqrt(SUN_UNIT_ROUNDOFF); + for (int j=0;j0) || !isfinite(increments[j])) return 1; + } + return 0; +} + +static int dense_difference(CvContext *context, sunrealtype t, N_Vector y, N_Vector fy, + SUNMatrix matrix, N_Vector trial, N_Vector ftrial, const double *increments) { + double *state=N_VGetArrayPointer(y), *test=N_VGetArrayPointer(trial); + double *base=N_VGetArrayPointer(fy), *value=N_VGetArrayPointer(ftrial); + memcpy(test,state,NSTATES*sizeof(double)); + for (int j=0;jrun->jacobian_colored_evals++; + return 0; +} + +static int cv_jacobian(sunrealtype t, N_Vector y, N_Vector fy, SUNMatrix matrix, + void *user, N_Vector tmp1, N_Vector tmp2, N_Vector tmp3) { + CvContext *context=user; + NativeRun *r=context->run; + double increments[NSTATES]; + (void)tmp3; + int flag=jac_increments(context,y,fy,increments); + if (flag) return flag; +#if defined(MODEL_JACOBIAN_CANONICAL_RHS) && MODEL_JACOBIAN_CANONICAL_RHS + /* The CVODE fy belongs to the ordinary RHS cache path. Recompute the + baseline using the same deterministic path as every perturbed probe; + mixing the two baselines would amplify cache roundoff by 1/increment. */ + flag=jac_rhs(context,t,y,tmp3); + if (flag) return flag; + fy=tmp3; +#endif + if (!context->colored) return dense_difference(context,t,y,fy,matrix,tmp2,tmp1,increments); + flag=colored_difference(context,t,y,fy,matrix,tmp2,tmp1,increments); + if (flag<0) return flag; /* cancellation/timeout must not trigger retries */ + if (flag>0) { + r->jacobian_fallbacks++; + return dense_difference(context,t,y,fy,matrix,tmp2,tmp1,increments); + } + if (context->reference) { + /* Diagnostic mode validates every entry, not a sampled submatrix. */ + flag=dense_difference(context,t,y,fy,context->reference,tmp2,tmp1,increments); + if (flag) return flag; + r->jacobian_checks++; + int mismatch=0; + for (int j=0;jreference,i,j)) mismatch=1; + if (mismatch) { + fprintf(stderr,"{\"event\":\"jacobian-verification-mismatch\",\"time\":%.17g,\"entries\":[",(double)t); + int written=0; + for (int j=0;jreference,i,j); + if (actual!=expected && written<12) { + fprintf(stderr,"%s{\"row\":%d,\"column\":%d,\"colored\":%.17g,\"dense\":%.17g}",written?",":"",i,j,actual,expected); + written++; + } + } + fprintf(stderr,"],\"state\":["); + for (int i=0;ijacobian_mismatches++; r->jacobian_fallbacks++; + context->colored=0; r->jacobian_colored=0; + if (SUNMatCopy(context->reference,matrix)) return -1; + } + } + return 0; +} +#endif + static void counters(NativeRun *r, void *solver) { long int value=0; CVodeGetNumErrTestFails(solver,&value); r->rejected+=(unsigned long)value; CVodeGetNumJacEvals(solver,&value); r->njev+=(unsigned long)value; CVodeGetNumLinSolvSetups(solver,&value); r->nlu+=(unsigned long)value; + CVodeGetNumRhsEvals(solver,&value); r->cvode_rhs+=(unsigned long)value; + CVodeGetNumLinRhsEvals(solver,&value); r->linear_rhs+=(unsigned long)value; } int native_bdf(NativeRun *r) { @@ -35,6 +201,7 @@ int native_bdf(NativeRun *r) { N_Vector y=N_VNew_Serial(NSTATES,ctx), atol=N_VNew_Serial(NSTATES,ctx), scratch=N_VNew_Serial(NSTATES,ctx); SUNMatrix matrix=NULL; SUNLinearSolver linear=NULL; void *solver=NULL; int success=0; + CvContext context={.run=r}; if (!y || !atol || !scratch) goto cleanup; memcpy(N_VGetArrayPointer(y),r->final_state,NSTATES*sizeof(double)); memcpy(N_VGetArrayPointer(atol),model_atol,NSTATES*sizeof(double)); @@ -44,11 +211,24 @@ int native_bdf(NativeRun *r) { if (!linear) goto cleanup; solver=CVodeCreate(CV_BDF,ctx); if (!solver) goto cleanup; + context.solver=solver; double t=r->options.start; - if (CVodeInit(solver,cv_rhs,t,y)<0 || CVodeSetUserData(solver,r)<0 || + if (CVodeInit(solver,cv_rhs,t,y)<0 || CVodeSetUserData(solver,&context)<0 || CVodeSVtolerances(solver,r->options.rtol,atol)<0 || CVodeSetLinearSolver(solver,linear,matrix)<0 || CVodeSetMaxStep(solver,r->options.max_step)<0) goto cleanup; +#if MODEL_JACOBIAN_COLORED + if (valid_coloring()) { + context.weights=N_VClone(y); + if (!context.weights) goto cleanup; + if (r->jacobian_verify) { + context.reference=SUNDenseMatrix(NSTATES,NSTATES,ctx); + if (!context.reference) goto cleanup; + } + context.colored=1; r->jacobian_colored=1; + if (CVodeSetJacFn(solver,cv_jacobian)<0) goto cleanup; + } +#endif r->starts++; CvDense dense={solver,scratch}; while (toptions.stop) { @@ -87,6 +267,8 @@ int native_bdf(NativeRun *r) { success=1; cleanup: if (solver) { counters(r,solver); CVodeFree(&solver); } + if (context.reference) SUNMatDestroy(context.reference); + if (context.weights) N_VDestroy(context.weights); if (linear) SUNLinSolFree(linear); if (matrix) SUNMatDestroy(matrix); if (y) N_VDestroy(y); diff --git a/native/runtime/main.c b/native/runtime/main.c index f84ef68..5b9957b 100644 --- a/native/runtime/main.c +++ b/native/runtime/main.c @@ -59,6 +59,13 @@ static int write_result(NativeRun *r, const char *path, const char *index_path) fprintf(f,"{\"success\":%s,\"status\":",!r->status?"true":"false"); json_string(f,r->status==0?"completed":r->status==1?"cancelled":"failed"); fprintf(f,",\"message\":"); json_string(f,r->message); + fprintf(f,",\"jacobianMode\":\"%s\",\"jacobianRhsCalls\":%lu," + "\"jacobianColoredEvals\":%lu,\"jacobianFallbacks\":%lu," + "\"jacobianChecks\":%lu,\"jacobianMismatches\":%lu," + "\"cvodeRhsCalls\":%lu,\"cvodeLinearRhsCalls\":%lu", + !r->options.bdf?"not-used":r->jacobian_colored?"colored-difference":"dense-difference", + r->jacobian_rhs,r->jacobian_colored_evals,r->jacobian_fallbacks, + r->jacobian_checks,r->jacobian_mismatches,r->cvode_rhs,r->linear_rhs); fprintf(f,",\"backend\":\"native-c\",\"method\":\"%s\",\"solver\":\"%s\",\"sundialsVersion\":\"%s\"," "\"simulatedUntil\":%.17g,\"solveSeconds\":%.17g,\"solveCpuSeconds\":%.17g," "\"nfev\":%lu,\"acceptedSteps\":%lu,\"rejectedSteps\":%lu,\"stateTransitions\":%lu," @@ -116,6 +123,7 @@ int main(int argc, char **argv) { vector(stdout,y,NSTATES); fputc('\n',stdout); return 0; } if (!strcmp(arg,"--solve-only")) { r.options.record_samples=0; continue; } + if (!strcmp(arg,"--verify-jacobian")) { r.jacobian_verify=1; continue; } if (i+1==argc) return 64; const char *value=argv[++i]; if (!strcmp(arg,"--output")) output=value; diff --git a/tests/fixtures/native-jacobian-cache-state.json b/tests/fixtures/native-jacobian-cache-state.json new file mode 100644 index 0000000..3fcff40 --- /dev/null +++ b/tests/fixtures/native-jacobian-cache-state.json @@ -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 + ] +} diff --git a/tests/manual/benchmark_native_jacobian.py b/tests/manual/benchmark_native_jacobian.py new file mode 100644 index 0000000..5e71a12 --- /dev/null +++ b/tests/manual/benchmark_native_jacobian.py @@ -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(" 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()) diff --git a/tests/manual/compare_jacobian_trajectories.py b/tests/manual/compare_jacobian_trajectories.py new file mode 100644 index 0000000..71305d4 --- /dev/null +++ b/tests/manual/compare_jacobian_trajectories.py @@ -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()) diff --git a/tests/manual/native_compute_profile.py b/tests/manual/native_compute_profile.py index 28d2959..227dbb6 100644 --- a/tests/manual/native_compute_profile.py +++ b/tests/manual/native_compute_profile.py @@ -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") diff --git a/tests/manual/summarize_jacobian_cost.py b/tests/manual/summarize_jacobian_cost.py new file mode 100644 index 0000000..3d01fc0 --- /dev/null +++ b/tests/manual/summarize_jacobian_cost.py @@ -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()) diff --git a/tests/test_native_codegen.py b/tests/test_native_codegen.py index d179d2f..4bb09f4 100644 --- a/tests/test_native_codegen.py +++ b/tests/test_native_codegen.py @@ -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() diff --git a/tests/test_native_jacobian_runtime.py b/tests/test_native_jacobian_runtime.py new file mode 100644 index 0000000..2722d9f --- /dev/null +++ b/tests/test_native_jacobian_runtime.py @@ -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 +#include +#include +#include +#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;i1) || (reject_mode==2 && perturbed>0))return 0; + if(zero_model){for(int i=0;i=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;jmatrix,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;i0 && 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;joracle,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;i0);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() diff --git a/tests/test_native_jacobian_structure.py b/tests/test_native_jacobian_structure.py new file mode 100644 index 0000000..1ca4fe1 --- /dev/null +++ b/tests/test_native_jacobian_structure.py @@ -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() diff --git a/tests/test_native_pipe_physics.py b/tests/test_native_pipe_physics.py index 2a4c791..8c230da 100644 --- a/tests/test_native_pipe_physics.py +++ b/tests/test_native_pipe_physics.py @@ -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'] diff --git a/tests/test_native_result_transport.py b/tests/test_native_result_transport.py index 1b2849c..1f271a1 100644 --- a/tests/test_native_result_transport.py +++ b/tests/test_native_result_transport.py @@ -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)