原生结果series通过字节索引直传,C端使用Ryu精确回读编码和64 KiB批量写出;网页采用Float64缓存和CSV工作线程,减少结果处理与保存等待。 补充八路AME曲线核查、全流程分阶段计时、独立编码基准和复现工具,固定后续优化采用修正八路及rtol=1e-8。C写出1.1808→0.1638 s,点击到可查看8.0100→6.9756 s。 验证:最终10项编码专项、29项相关后端回归通过;8份原生结果逐位一致,16次网页结果/CSV/刷新恢复通过。前端构建及缓存/CSV专项在本轮结果处理工作中通过。环境、原始大结果与临时构建不纳入Git。
343 lines
24 KiB
Python
343 lines
24 KiB
Python
"""Summarize the four complete C-result-encoding browser groups.
|
|
|
|
.venv/bin/python tests/manual/summarize_native_encoding.py \
|
|
--root test/c-result-encoding-20260911
|
|
|
|
Reads small timing metadata only, never result arrays or CSV contents. Every
|
|
browser group must contain one warmup and three measured successful runs.
|
|
Missing/inconsistent evidence exits 2 and writes complete:false plus an empty
|
|
CSV, so an earlier successful summary cannot masquerade as current evidence.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import csv
|
|
import hashlib
|
|
import json
|
|
import math
|
|
from pathlib import Path
|
|
from statistics import median
|
|
import sys
|
|
from typing import Any
|
|
|
|
REPO = Path(__file__).resolve().parents[2]
|
|
GROUPS = {
|
|
"baseline": ("control", None),
|
|
"baseline-profiled": ("profiled", "baseline-source/profiled-backend/requests"),
|
|
"optimized": ("control", None),
|
|
"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")
|
|
NATIVE_IDENTITY = ("backend", "method", "solver", "sundialsVersion", "simulatedUntil", "maxAcceptedStep", *COUNTERS)
|
|
C_WALL = ("argumentPreparationSeconds", "initializationSeconds", "integrationSeconds",
|
|
"finalSampleAndStatusSeconds", "projectionSeconds", "jsonWriteSeconds", "mainTotalSeconds")
|
|
CSV_FIELDS = ("group", "phase", "run", "simulationId", "domain", "metric", "statistic", "value", "unit",
|
|
"parent", "denominatorValue", "percentOfParent", "baselineValue", "optimizedValue",
|
|
"count", "missingCount", "inclusion", "source")
|
|
DEFINITIONS = {
|
|
"scope": "One C-result-encoding experiment. Baseline/optimized control groups alone provide end-to-end comparisons; profiled C-write comparisons are separate diagnostics.",
|
|
"statistics": "Run values precede median/min/max. Stage/parent percentages use each run's own denominator before aggregation. Before/after changes use the ratio of independently collected group medians; medians and overlapping stages must not be added.",
|
|
"comparison": "Groups were collected separately, not as alternating paired trials. Duration reduction = (baseline median - optimized median) / baseline median; speedup = baseline median / optimized median. Run ordinals are not matched pairs. Ordering, scheduling and thermal variability remain possible.",
|
|
"ready": "Click to DOM-observed successful completion and an enabled Run button, not GPU completion.",
|
|
"saved": "End-to-end comparisons use pointer polling observation in BOTH control groups; includes polling and scheduling latency. Exact instrumented pointer publication remains a separate profiled metric.",
|
|
"csv": "Click through Playwright download notification and saveAs completion; includes automation and filesystem work.",
|
|
"backend": "Backend spans are inclusive wall intervals; children are included in their parents. ASGI send awaits are not pure network time. Response serialization includes metadata encoding and raw numeric-fragment joining.",
|
|
"cWrite": "C output write includes numeric encoding, stdio writes, close and index writing. CPU and wall are distinct observations; their difference is not an isolated disk-I/O measurement.",
|
|
"cSolve": "Integration includes CVODE setup, RHS/Jacobian/linear work, events and sampling. Counts are not CPU-time shares. Projection is outside integration and inside C main.",
|
|
"process": "Native reported processWallSeconds includes Python result reading after child exit; observed process lifetime spans include spawn and exit-observation latency.",
|
|
"overlap": "Browser reads overlap backend work. Parse/decode lie inside reception; persistence and rendering overlap. Per-stage percentages are inclusive and must not be added.",
|
|
"warmup": "Warmup rows are retained separately. Formal browser runs require build-cache hits. CacheHit, not the warmup label, identifies cold compilation.",
|
|
"replay": "Microbenchmark replays preloaded contiguous binary64 values, excluding model projection and production strided access. Its medians are separate and never substituted for end-to-end results.",
|
|
"validation": "Timing metadata validates group completeness, input/assets, counters, sample/variable counts and recorded success/restore flags. It does not independently prove numerical bitwise parity; use the separate full-result comparison artifact.",
|
|
}
|
|
|
|
|
|
def numeric(value: Any) -> bool:
|
|
return type(value) in (int, float) and math.isfinite(value)
|
|
|
|
|
|
def require(condition: bool, message: str) -> None:
|
|
if not condition:
|
|
raise ValueError(message)
|
|
|
|
|
|
def stats(values: list[Any]) -> dict:
|
|
present = [v for v in values if numeric(v)]
|
|
return {"count": len(present), "missingCount": len(values) - len(present), "values": values,
|
|
"median": median(present) if present else None,
|
|
"min": min(present) if present else None, "max": max(present) if present else None}
|
|
|
|
|
|
def percent(value: Any, denominator: Any) -> float | None:
|
|
return value / denominator * 100 if numeric(value) and numeric(denominator) and denominator > 0 else None
|
|
|
|
|
|
def finite_field(data: dict, name: str, context: str, *, positive: bool = False) -> float:
|
|
value = data.get(name)
|
|
require(numeric(value) and (value > 0 if positive else value >= 0), f"{context}: missing/invalid {name}")
|
|
return value
|
|
|
|
|
|
class Summarizer:
|
|
def __init__(self, root: Path):
|
|
self.root = root.resolve()
|
|
self.sources: dict[str, dict] = {}
|
|
self.observations: list[dict] = []
|
|
self.simulation_ids: set[str] = set()
|
|
|
|
def read(self, relative: str) -> dict:
|
|
path = (self.root / relative).resolve()
|
|
require(path.is_relative_to(self.root), f"Metadata path leaves experiment root: {relative}")
|
|
require(path.is_file(), f"Incomplete experiment: missing {relative}")
|
|
require(path.stat().st_size <= 4 * 1024 * 1024, f"Refusing large non-metadata input: {relative}")
|
|
raw = path.read_bytes()
|
|
value = json.loads(raw, parse_constant=lambda token: (_ for _ in ()).throw(ValueError(f"Invalid JSON number: {token}")))
|
|
require(isinstance(value, dict), f"Expected metadata object: {relative}")
|
|
self.sources[relative] = {"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}
|
|
return value
|
|
|
|
def observe(self, run: dict, domain: str, metric: str, value: Any, unit: str = "ms", *,
|
|
parent: str = "", denominator: Any = None, inclusion: str = "inclusive/overlapping; not additive",
|
|
source: str = "", baseline: Any = None, optimized: Any = None) -> None:
|
|
require(value is None or numeric(value), f"Invalid observation {domain}.{metric}: {value!r}")
|
|
self.observations.append({k: run[k] for k in ("group", "phase", "run", "simulationId")} | {
|
|
"domain": domain, "metric": metric, "value": value, "unit": unit, "parent": parent,
|
|
"denominatorValue": denominator, "percentOfParent": percent(value, denominator),
|
|
"baselineValue": baseline, "optimizedValue": optimized, "inclusion": inclusion, "source": source})
|
|
|
|
def backend(self, run: dict, relative: str) -> dict:
|
|
data = self.read(relative)
|
|
context = f"{run['group']}/{run['run']} backend"
|
|
require(data.get("id") == run["simulationId"], f"{context}: simulationId mismatch")
|
|
require(data.get("httpStatus") == 200, f"{context}: HTTP did not succeed")
|
|
native = run["native"]
|
|
for key in (*NATIVE_IDENTITY, "solveSeconds", "solveCpuSeconds", "buildKey", "cacheHit"):
|
|
require(data.get("native", {}).get(key) == native.get(key), f"{context}: browser/backend mismatch for {key}")
|
|
require(data.get("sampleCount") == run["sampleCount"], f"{context}: backend sampleCount mismatch")
|
|
http = finite_field(data, "httpTotalSeconds", context, positive=True) * 1000
|
|
self.observe(run, "backend", "httpTotalMs", http, source=relative, inclusion=DEFINITIONS["backend"])
|
|
spans = data.get("spans", [])
|
|
require(bool(spans), f"{context}: missing backend spans")
|
|
totals: dict[str, float] = {}
|
|
for span in spans:
|
|
start = finite_field(span, "startMs", context)
|
|
end = finite_field(span, "endMs", context)
|
|
require(start <= end <= http + 1e-5, f"{context}: span outside HTTP interval: {span['name']}")
|
|
totals[span["name"]] = totals.get(span["name"], 0) + end - start
|
|
for name in ("native_indexed_result_read", "native_process_lifetime_observed", "response_result_json_serialization"):
|
|
require(name in totals, f"{context}: missing {name}")
|
|
for name, value in totals.items():
|
|
self.observe(run, "backend_span", name, value, parent="httpTotalMs", denominator=http,
|
|
inclusion=DEFINITIONS["backend"], source=relative)
|
|
c = data.get("nativeStages", {})
|
|
main_ms = finite_field(c, "mainTotalSeconds", context, positive=True) * 1000
|
|
for name in C_WALL:
|
|
self.observe(run, "c_wall", name.removesuffix("Seconds") + "Ms", finite_field(c, name, context) * 1000,
|
|
parent="cMainMs" if name != "mainTotalSeconds" else "",
|
|
denominator=main_ms if name != "mainTotalSeconds" else None,
|
|
inclusion=DEFINITIONS["cWrite"] if name == "jsonWriteSeconds" else DEFINITIONS["cSolve"], source=relative)
|
|
for name in ("projectionCpuSeconds", "jsonWriteCpuSeconds"):
|
|
self.observe(run, "c_cpu", name.removesuffix("Seconds") + "Ms", finite_field(c, name, context) * 1000,
|
|
inclusion="CPU duration; separate from wall intervals", source=relative)
|
|
self.observe(run, "backend", "responseSendAwaitMs", finite_field(data, "responseSendAwaitSeconds", context) * 1000,
|
|
parent="httpTotalMs", denominator=http, inclusion=DEFINITIONS["backend"], source=relative)
|
|
for name in ("rawSeriesBytes", "responseBodyBytes"):
|
|
self.observe(run, "size", name, finite_field(data, name, context, positive=True), "bytes", source=relative)
|
|
phases = data.get("existingPerformance", {}).get("phases", {})
|
|
for name, phase in phases.items():
|
|
self.observe(run, "backend_existing", name, finite_field(phase, "inclusiveNs", context) / 1e6,
|
|
parent="httpTotalMs", denominator=http,
|
|
inclusion="Inclusive duration without aligned start/end; not an exclusive extra cost", source=relative)
|
|
return {"source": relative, "xmlSha256": data.get("xmlSha256"), "httpTotalMs": http,
|
|
"spans": spans, "nativeStages": c, "process": data.get("process"), "build": data.get("build")}
|
|
|
|
def 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}: missing errors list or reported browser errors")
|
|
rows = data.get("rows", [])
|
|
require(len(rows) == 4, f"Incomplete {name}: expected 1 warmup + 3 measured rows, got {len(rows)}")
|
|
require(sorted(r.get("run", -1) for r in rows) == [0, 1, 2, 3], f"{name}: unexpected/duplicate run numbers")
|
|
runs = []
|
|
for row in sorted(rows, key=lambda r: r["run"]):
|
|
context = f"{name}/{row['run']}"
|
|
require(row.get("mode") == mode and row.get("deep") is False, f"{context}: wrong instrumentation mode")
|
|
require(row.get("warmup") is (row["run"] == 0), f"{context}: warmup label mismatch")
|
|
sid = row.get("simulationId")
|
|
require(isinstance(sid, str) and bool(sid) and sid not in self.simulation_ids, f"{context}: missing/duplicate simulationId")
|
|
self.simulation_ids.add(sid)
|
|
native = row.get("native", {})
|
|
require(native.get("success") is True and native.get("status") == "completed", f"{context}: native simulation failed")
|
|
require(row.get("restoredIdentical") is True, f"{context}: restore parity was not confirmed")
|
|
require(type(native.get("cacheHit")) is bool, f"{context}: missing cacheHit")
|
|
if row["run"]:
|
|
require(native["cacheHit"], f"{context}: measured run includes a cold build")
|
|
for key in NATIVE_IDENTITY:
|
|
require(key in native and native[key] is not None, f"{context}: missing native {key}")
|
|
for key in COUNTERS:
|
|
value = finite_field(native, key, context)
|
|
require(int(value) == value, f"{context}: noninteger counter {key}")
|
|
run = {"group": name, "phase": "warmup" if row["warmup"] else "measured", "run": row["run"],
|
|
"simulationId": sid, "native": native, "original": row,
|
|
"sampleCount": finite_field(row, "sampleCount", context, positive=True),
|
|
"variableCount": finite_field(row, "variableCount", context, positive=True)}
|
|
for key in GOALS.values():
|
|
finite_field(row, key, context, positive=True)
|
|
if mode == "profiled":
|
|
for key in ("resultParseMs", "synchronousStreamDecodeMs", "clickToIndexedDbCommitMs", "streamBytes"):
|
|
finite_field(row, key, context, positive=True)
|
|
for key, value in row.items():
|
|
if key.endswith(("Ms", "Bytes")) and (value is None or numeric(value)):
|
|
self.observe(run, "frontend", key, value, "bytes" if key.endswith("Bytes") else "ms", source=relative)
|
|
for key in ("solveSeconds", "solveCpuSeconds", "processWallSeconds", "buildSeconds"):
|
|
self.observe(run, "native", key.removesuffix("Seconds") + "Ms", finite_field(native, key, context) * 1000,
|
|
source=relative, inclusion=DEFINITIONS["process"] if key == "processWallSeconds" else "Native-reported timing; CPU and wall are separate")
|
|
run["backend"] = self.backend(run, f"{backend_root}/{sid}/stages.json") if backend_root else None
|
|
runs.append(run)
|
|
for key in ("inputSha256", "buildAssetSetSha256"):
|
|
value = data.get(key)
|
|
require(isinstance(value, str) and len(value) == 64 and all(c in "0123456789abcdef" for c in value), f"{name}: missing/invalid {key}")
|
|
require(bool(data.get("servedAssets")), f"{name}: missing served frontend assets")
|
|
return {"source": relative, "mode": mode, "inputSha256": data["inputSha256"],
|
|
"buildAssetSetSha256": data["buildAssetSetSha256"], "servedAssets": data["servedAssets"],
|
|
"browser": data.get("browser"), "node": data.get("node"), "scriptSha256": data.get("scriptSha256"),
|
|
"sourceDefinitions": data.get("definitions"), "measuredRunCount": 3, "warmupRunCount": 1, "runs": runs}
|
|
|
|
def comparisons(self, groups: dict, before: str, after: str, metrics: dict, *, diagnostic: bool) -> list[dict]:
|
|
comparison = []
|
|
for target, (source_domain, metric) in metrics.items():
|
|
summaries = []
|
|
for name in (before, after):
|
|
rows = [o for o in self.observations if o["group"] == name and o["phase"] == "measured"
|
|
and o["domain"] == source_domain and o["metric"] == metric]
|
|
require(len(rows) == 3 and all(numeric(r["value"]) and r["value"] > 0 for r in rows),
|
|
f"Missing comparison metric {name}/{target}")
|
|
summaries.append(stats([r["value"] for r in sorted(rows, key=lambda r: r["run"])]))
|
|
baseline, optimized = summaries
|
|
old, new = baseline["median"], optimized["median"]
|
|
comparison.append({"target": target, "metric": f"{source_domain}.{metric}", "before": before, "after": after,
|
|
"diagnosticOnly": diagnostic, "statistic": "ratio_of_group_medians", "definition": DEFINITIONS["comparison"],
|
|
"source": f"{groups[before]['source']} | {groups[after]['source']}",
|
|
"baselineMs": baseline, "optimizedMs": optimized, "savedMs": old - new,
|
|
"durationReductionPercent": (old - new) / old * 100, "speedupRatio": old / new})
|
|
return comparison
|
|
|
|
def replay(self) -> dict:
|
|
relative = "replay/summary.json"
|
|
data = self.read(relative)
|
|
require(data.get("allRealFileBinary64Parity") is True, "Replay real-file binary64 verification not complete")
|
|
supplied = data.get("medians", {})
|
|
require(bool(supplied.get("file")), "Replay real-file medians missing")
|
|
runs = data.get("runs", [])
|
|
require(bool(runs), "Replay run metadata missing")
|
|
for row in runs:
|
|
require(row.get("success") is True, "Replay includes a failed run")
|
|
run = {"group": f"replay:{row['sink']}:{row['variant']}", "phase": "warmup" if row["warmup"] else "measured",
|
|
"run": row["run"], "simulationId": ""}
|
|
for metric, unit in (("wallSeconds", "ms"), ("cpuSeconds", "ms"), ("encodedBytes", "bytes")):
|
|
value = finite_field(row, metric, run["group"], positive=True)
|
|
self.observe(run, "replay", metric.removesuffix("Seconds") + "Ms" if unit == "ms" else metric,
|
|
value * 1000 if unit == "ms" else value, unit, source=relative, inclusion=DEFINITIONS["replay"])
|
|
medians = {sink: {variant: {k: value[k] for k in ("wallSeconds", "cpuSeconds", "encodedBytes")}
|
|
for variant, value in variants.items()} for sink, variants in supplied.items()}
|
|
return {"source": relative, "suppliedMedians": medians, "runs": runs,
|
|
"timingContract": data.get("prepared", {}).get("timingContract"), "limitation": data.get("limitation"),
|
|
"usedForEndToEndComparison": False}
|
|
|
|
def build(self) -> dict:
|
|
groups = {name: self.group(name, *config) for name, config in GROUPS.items()}
|
|
reference = groups["baseline"]
|
|
first = reference["runs"][0]
|
|
for name, group in groups.items():
|
|
for key in ("inputSha256", "buildAssetSetSha256", "browser", "node"):
|
|
require(group[key] is not None and group[key] == reference[key], f"Cross-group {key} mismatch: {name}")
|
|
for run in group["runs"]:
|
|
for key in ("sampleCount", "variableCount"):
|
|
require(run[key] == first[key], f"Cross-run {key} mismatch: {name}/{run['run']}")
|
|
for key in NATIVE_IDENTITY:
|
|
require(run["native"][key] == first["native"][key], f"Cross-run native {key} mismatch: {name}/{run['run']}")
|
|
xml_hashes = [run["backend"]["xmlSha256"] for group in groups.values() for run in group["runs"] if run["backend"]]
|
|
require(all(isinstance(value, str) and len(value) == 64 for value in xml_hashes) and len(set(xml_hashes)) == 1,
|
|
"Profiled input XML SHA missing or mismatched")
|
|
goals = self.comparisons(groups, "baseline", "optimized",
|
|
{key: ("frontend", metric) for key, metric in GOALS.items()}, diagnostic=False)
|
|
stages = self.comparisons(groups, "baseline-profiled", "optimized-profiled",
|
|
{"write_wall": ("c_wall", "jsonWriteMs"), "write_cpu": ("c_cpu", "jsonWriteCpuMs")}, diagnostic=True)
|
|
replay = self.replay()
|
|
buckets: dict[tuple, list[dict]] = {}
|
|
keys = ("group", "phase", "domain", "metric", "unit", "parent")
|
|
for observation in self.observations:
|
|
buckets.setdefault(tuple(observation[key] for key in keys), []).append(observation)
|
|
aggregates = []
|
|
for key, rows in sorted(buckets.items()):
|
|
require(len({r["run"] for r in rows}) == len(rows), f"Duplicate per-run observation: {key}")
|
|
aggregates.append(dict(zip(keys, key)) | stats([r["value"] for r in rows]) | {
|
|
"percentOfParent": stats([r["percentOfParent"] for r in rows]),
|
|
"runs": [{k: row[k] for k in ("run", "simulationId")} for row in rows]})
|
|
return {"schemaVersion": 1, "complete": True, "errors": [], "experimentRoot": str(self.root),
|
|
"scriptSha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(), "definitions": DEFINITIONS,
|
|
"sourceFiles": self.sources, "groups": groups, "changes": {"endToEnd": goals, "cWriteDiagnostics": stages},
|
|
"replay": replay, "observations": self.observations, "aggregates": aggregates,
|
|
"validation": {"groupCount": 4, "browserRunCount": 16, "warmupCount": 4, "measuredCount": 12,
|
|
"inputSha256": reference["inputSha256"], "frontendAssetSetSha256": reference["buildAssetSetSha256"],
|
|
"profiledXmlSha256": xml_hashes[0], "sampleCount": first["sampleCount"], "variableCount": first["variableCount"],
|
|
"nativeIdentity": {key: first["native"][key] for key in NATIVE_IDENTITY},
|
|
"fullNumericalParityIndependentlyChecked": False}}
|
|
|
|
|
|
def write_outputs(root: Path, summary: dict) -> None:
|
|
root.mkdir(parents=True, exist_ok=True)
|
|
with (root / "timings.csv").open("w", encoding="utf-8", newline="") as stream:
|
|
writer = csv.DictWriter(stream, fieldnames=CSV_FIELDS)
|
|
writer.writeheader()
|
|
for row in summary.get("observations", []):
|
|
writer.writerow(row | {"statistic": "run", "count": int(numeric(row["value"])), "missingCount": int(row["value"] is None)})
|
|
for entry in summary.get("aggregates", []):
|
|
for statistic in ("median", "min", "max"):
|
|
writer.writerow({k: entry[k] for k in ("group", "phase", "domain", "metric", "unit", "parent", "count", "missingCount")} | {
|
|
"statistic": statistic, "value": entry[statistic], "percentOfParent": entry["percentOfParent"][statistic],
|
|
"inclusion": "Per-run values and percentages aggregated separately; medians are not additive"})
|
|
for category, changes in summary.get("changes", {}).items():
|
|
for change in changes:
|
|
for key, unit in (("durationReductionPercent", "percent"), ("speedupRatio", "ratio"), ("savedMs", "ms")):
|
|
writer.writerow({"group": f"{change['after']}-vs-{change['before']}", "phase": "measured",
|
|
"domain": f"comparison:{category}", "metric": f"{change['target']}.{key}",
|
|
"statistic": "ratio_of_group_medians" if key != "savedMs" else "difference_of_group_medians",
|
|
"value": change[key], "unit": unit, "baselineValue": change["baselineMs"]["median"],
|
|
"optimizedValue": change["optimizedMs"]["median"], "count": 3, "missingCount": 0,
|
|
"inclusion": ("Profiled C-write diagnostic only; " if change["diagnosticOnly"] else "") + change["definition"],
|
|
"source": change["source"]})
|
|
(root / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2, allow_nan=False) + "\n", encoding="utf-8")
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--root", type=Path, default=REPO / "test/c-result-encoding-20260911")
|
|
args = parser.parse_args()
|
|
root = args.root.resolve()
|
|
if not root.is_relative_to(REPO / "test"):
|
|
parser.error("--root must be beneath the repository's ignored test/ directory")
|
|
summarizer = Summarizer(root)
|
|
try:
|
|
summary = summarizer.build()
|
|
except (OSError, ValueError, KeyError, TypeError) as error:
|
|
summary = {"schemaVersion": 1, "complete": False, "errors": [str(error)],
|
|
"experimentRoot": str(root), "sourceFiles": summarizer.sources,
|
|
"note": "No partial timing statistics are published. Complete/fix all groups and rerun."}
|
|
write_outputs(root, summary)
|
|
print(json.dumps(summary, ensure_ascii=False, indent=2), file=sys.stderr)
|
|
return 2
|
|
write_outputs(root, summary)
|
|
print(json.dumps({"complete": True, "validation": summary["validation"],
|
|
"observations": len(summary["observations"]), "outputs": [str(root / name) for name in ("summary.json", "timings.csv")]}, ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|