Files
FreeCADWasmDev/tools/prepare_full_parity_module_batch.py
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

553 lines
26 KiB
Python

#!/usr/bin/env python3
"""Select one bounded, source-backed module batch for high-throughput parity work.
The planner does not change dispositions or task status. It only materializes a
small deterministic work packet so one implementation turn can close several
records that share a native adapter family.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import sys
from collections import Counter, defaultdict
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from implementation_first import all_implementation_ready_ids, implementation_first_enabled # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
DOCS = ROOT / "docs"
P6 = DOCS / "p6"
STATE = DOCS / "current/task-state.json"
OUTPUT = DOCS / "current/module-batch-manifest.json"
SCHEDULER_STATUS = DOCS / "current/freecad-web-scheduler-status.json"
SCHEDULER_TIMING = DOCS / "current/freecad-web-scheduler-timing.json"
TASK_STATE = DOCS / "current/task-state.json"
CONTINUATION_STATE = DOCS / "current/continuation-state.json"
EVIDENCE_INDEX = DOCS / "current/module-batch-evidence-index.json"
PREFERRED_MODULES = {
"Part": 0,
"PartDesign": 1,
"Sketcher": 2,
"TechDraw": 3,
"Draft": 4,
"Material": 5,
"Mesh": 6,
"Import": 7,
"BIM": 8,
"Assembly": 9,
"Fem": 10,
}
TIER_ORDER = {"tier-1-readonly": 0, "tier-2-pure": 1, "tier-3-stateful": 2, "tier-4-restricted": 3}
FAMILY_ORDER = {
"pure-function": 0,
"geometry-value": 1,
"generic-object": 2,
"property": 3,
"document-graph": 4,
"serialization": 5,
"lifecycle-command": 6,
"view-resource": 7,
}
UNRESOLVED = {"planned", "not_implemented"}
TERMINAL_RECORD_STATES = {
"exposed", "restricted", "nonportable", "blocked_native_probe",
"passed", "passed_boundary", "pass", "completed", "native_handler",
}
_PENDING_EVIDENCE_CACHE: set[str] | None = None
def adaptive_target_size(requested: int | None, tier: str, module: str, family: str) -> tuple[int, str]:
"""Adjust a family using recent complete-cycle throughput and failures."""
base = requested if requested is not None else 24
if requested is not None:
return max(4, min(64, requested)), "explicit"
reason = "default"
try:
timing = json.loads(SCHEDULER_TIMING.read_text(encoding="utf-8"))
samples: list[tuple[float, int, bool]] = []
for cycle in reversed(timing.get("cycles", [])):
batch = cycle.get("batch", {}) if isinstance(cycle, dict) else {}
if (batch.get("module"), batch.get("tier"), batch.get("family")) != (module, tier, family):
continue
duration = float(
cycle.get(
"effectiveCycleSeconds",
float(cycle.get("turnSeconds", 0) or 0)
+ float(cycle.get("preparationSeconds", 0) or 0)
+ float(cycle.get("postPipelineSeconds", 0) or 0),
) or 0
)
activity = cycle.get("activity", {}) if isinstance(cycle.get("activity"), dict) else {}
failed = bool(cycle.get("timedOut")) or int(activity.get("commandFailures", 0) or 0) > 0 or bool(cycle.get("commandBudgetExceeded"))
samples.append((duration, max(4, int(batch.get("records", base) or base)), failed))
if len(samples) == 3:
break
if samples:
durations = [value[0] for value in samples if value[0] > 0]
previous_size = samples[0][1]
if any(value[2] for value in samples):
return max(4, previous_size // 2), "same_family_recent_failure"
if durations:
average = sum(durations) / len(durations)
maximum = max(durations)
records_per_second = sum(value[1] for value in samples) / sum(durations)
if maximum > 900:
return max(4, previous_size // 2), "same_family_effective_over_900s"
if average > 420:
return max(4, previous_size - 4), "same_family_average_over_420s"
if average < 90 and records_per_second >= 0.25:
return min(32, previous_size + 8), "same_family_three_cycle_fast_throughput"
if average < 240:
return min(32, previous_size + 4), "same_family_three_cycle_under_240s"
except (OSError, json.JSONDecodeError, TypeError, ValueError):
pass
try:
status = json.loads(SCHEDULER_STATUS.read_text(encoding="utf-8"))
previous = (status.get("moduleBatchModule"), status.get("moduleBatchTier"), status.get("moduleBatchFamily"))
current = (module, tier, family)
duration = float(status.get("lastTurnDurationSeconds", 0) or 0) + float(status.get("postTurnPipelineSeconds", 0) or 0)
timed_out = bool(status.get("timedOut"))
if previous == current and (timed_out or duration > 900):
base = max(4, base // 2)
reason = "previous_timeout_or_over_900s"
elif previous == current and duration and duration < 240 and not timed_out:
base = min(32, base + 4)
reason = "previous_effective_under_240s"
except (OSError, json.JSONDecodeError, TypeError, ValueError):
pass
return max(4, min(64, base)), reason
def completed_evidence_ids() -> set[str]:
"""Exclude records already covered by a persisted batch evidence file.
Some native object gates write batch evidence before the canonical matrix
projection. Reading only the compact evidence pointer prevents planner
fast-path loops from selecting the same records again.
"""
global _PENDING_EVIDENCE_CACHE
pointers: set[str] = set()
pending_from_scan: set[str] = set()
evidence_paths: set[Path] = set()
for path in (TASK_STATE, CONTINUATION_STATE):
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
candidates = []
for key in ("batch_checkpoint", "batchCheckpoint", "currentBatchRecovery"):
value = payload.get(key)
if isinstance(value, dict) and isinstance(value.get("evidence"), str):
candidates.append(value["evidence"])
for candidate in candidates:
evidence_path = ROOT / candidate
evidence_paths.add(evidence_path)
# Canonical manifests may point at family-level runtime evidence. Include
# those paths, but inspect each record's own status below: a pass_with_
# boundaries file often contains both exposed and method_evidence_pending
# records, and the latter must remain schedulable.
for manifest_name in ("python-api-manifest.json", "object-contract-matrix.json", "workbench-command-matrix.json"):
try:
manifest = json.loads((P6 / manifest_name).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
for row in manifest.get("records", []) if isinstance(manifest, dict) else []:
if not isinstance(row, dict):
continue
for candidate in row.get("dispositionEvidence", []) if isinstance(row.get("dispositionEvidence"), list) else []:
if isinstance(candidate, str):
evidence_paths.add(ROOT / candidate)
# A stale manifest can survive an interrupted turn after the checkpoint
# moves on. Include bounded batch evidence files so planner selection is
# idempotent even when no compact pointer names the stale batch.
if P6.exists():
evidence_paths.update(P6.glob("*module-batch*runtime.json"))
try:
cached_payload = json.loads(EVIDENCE_INDEX.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
cached_payload = {}
cached_entries = cached_payload.get("entries", {}) if isinstance(cached_payload, dict) and cached_payload.get("schema") == "freecad-web-module-batch-evidence-index/v4" else {}
if not isinstance(cached_entries, dict):
cached_entries = {}
next_entries: dict[str, dict[str, object]] = {}
cache_changed = False
for evidence_path in sorted(evidence_paths, key=str):
try:
stat = evidence_path.stat()
except OSError:
continue
cache_key = str(evidence_path.relative_to(ROOT)) if evidence_path.is_relative_to(ROOT) else str(evidence_path)
cached = cached_entries.get(cache_key) if isinstance(cached_entries.get(cache_key), dict) else {}
if (
cached.get("size") == stat.st_size
and cached.get("mtime_ns") == stat.st_mtime_ns
and cached.get("inode") == stat.st_ino
and isinstance(cached.get("terminalIds"), list)
and isinstance(cached.get("pendingIds"), list)
):
next_entries[cache_key] = cached
pointers.update(value for value in cached["terminalIds"] if isinstance(value, str))
pending_from_scan.update(value for value in cached["pendingIds"] if isinstance(value, str))
continue
try:
evidence = json.loads(evidence_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
evidence_state = str(evidence.get("status") or (evidence.get("result") if isinstance(evidence.get("result"), str) else "") or "")
if not (evidence_state in {"pass", "passed", "pass_with_boundaries", "completed", "blocked_native_probe", "pass_with_restricted_and_pending"} or evidence_state.startswith("pass_with_")):
continue
result_payload = evidence.get("result") if isinstance(evidence.get("result"), dict) else {}
rows = evidence.get("records", []) if isinstance(evidence.get("records"), list) else result_payload.get("records", [])
top_level_state = str(evidence.get("status") or "")
is_batch_evidence = "module-batch" in evidence_path.name or "module-batch" in str(evidence.get("schema", ""))
terminal_ids: list[str] = []
pending_ids: list[str] = []
for row in rows:
if isinstance(row, dict):
record_id = row.get("apiId") or row.get("id")
record_state = str(row.get("status") or row.get("result") or "")
if isinstance(record_id, str) and (
top_level_state == "blocked_native_probe"
or record_state in TERMINAL_RECORD_STATES
):
pointers.add(record_id)
terminal_ids.append(record_id)
elif is_batch_evidence and isinstance(record_id, str) and record_state in UNRESOLVED:
pending_ids.append(record_id)
pending_from_scan.add(record_id)
next_entries[cache_key] = {
"size": stat.st_size,
"mtime_ns": stat.st_mtime_ns,
"inode": stat.st_ino,
"terminalIds": sorted(set(terminal_ids)),
"pendingIds": sorted(set(pending_ids)),
}
cache_changed = True
if set(next_entries) != set(cached_entries):
cache_changed = True
if cache_changed:
EVIDENCE_INDEX.parent.mkdir(parents=True, exist_ok=True)
temporary = EVIDENCE_INDEX.with_suffix(".tmp")
temporary.write_text(
json.dumps({"schema": "freecad-web-module-batch-evidence-index/v4", "entries": next_entries}, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
temporary.replace(EVIDENCE_INDEX)
_PENDING_EVIDENCE_CACHE = pending_from_scan
return pointers
def pending_evidence_ids() -> set[str]:
"""Return records with a real runtime attempt but no terminal status yet."""
if _PENDING_EVIDENCE_CACHE is not None:
return set(_PENDING_EVIDENCE_CACHE)
try:
payload = json.loads(EVIDENCE_INDEX.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return set()
entries = payload.get("entries", {}) if isinstance(payload, dict) else {}
pending: set[str] = set()
for entry in entries.values() if isinstance(entries, dict) else []:
if isinstance(entry, dict):
pending.update(value for value in entry.get("pendingIds", []) if isinstance(value, str))
return pending
def load_records(path: Path, kind: str) -> list[dict]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
return []
rows = data.get("records", []) if isinstance(data, dict) else []
result = []
for row in rows:
if not isinstance(row, dict):
continue
module = row.get("module")
if not module:
continue
if kind == "python":
disposition = str(row.get("disposition", "planned"))
if os.environ.get("FREECAD_IMPLEMENT_BOUNDARIES", "0").lower() in {"1", "true", "yes", "on"} and disposition in {"restricted", "nonportable"}:
disposition = "not_implemented"
result.append(
{
"id": row.get("id"),
"module": module,
"kind": row.get("kind", "function"),
"name": row.get("name"),
"family": row.get("family", "other"),
"tier": row.get("tier", "tier-2-pure"),
"disposition": disposition,
"dispositionEvidence": row.get("dispositionEvidence"),
"source": row.get("evidence", {}).get("path") if isinstance(row.get("evidence"), dict) else None,
"sourceLine": row.get("evidence", {}).get("line") if isinstance(row.get("evidence"), dict) else None,
}
)
elif kind == "object":
status = str(row.get("status", "planned"))
disposition = status if status in {"exposed", "passed", "pass", "completed"} else "not_implemented"
result.append(
{
"id": row.get("id"),
"module": module,
"kind": "object",
"name": row.get("name"),
"family": row.get("adapterFamily", "other"),
"tier": "object-contract",
"disposition": disposition,
"dispositionEvidence": row.get("dispositionEvidence"),
"source": (row.get("evidence") or [{}])[0].get("path") if isinstance(row.get("evidence"), list) and row.get("evidence") else None,
"sourceLine": (row.get("evidence") or [{}])[0].get("line") if isinstance(row.get("evidence"), list) and row.get("evidence") else None,
}
)
elif kind == "workbench":
disposition = str(row.get("disposition", "restricted"))
if os.environ.get("FREECAD_IMPLEMENT_BOUNDARIES", "0").lower() in {"1", "true", "yes", "on"} and disposition == "restricted":
disposition = "not_implemented"
result.append(
{
"id": row.get("id"),
"module": module,
"kind": row.get("kind", "desktop-gui"),
"name": row.get("command"),
"family": "workbench-command",
"tier": "tier-4-restricted",
"disposition": disposition,
"dispositionEvidence": row.get("dispositionEvidence"),
"source": row.get("sourcePath"),
"sourceLine": row.get("sourceLine"),
}
)
elif kind == "format":
status = str(row.get("status", "passed_boundary"))
result.append(
{
"id": row.get("id"),
"module": module,
"kind": "format-handler",
"name": f"{row.get('direction', 'unknown')}:{row.get('extension', '')}",
"family": f"format-{row.get('direction', 'unknown')}",
"tier": "format-handler",
"disposition": "exposed" if status == "passed" else "not_implemented",
"dispositionEvidence": row.get("dispositionEvidence"),
"source": row.get("path"),
"sourceLine": row.get("line"),
}
)
return [row for row in result if row.get("id") and row.get("name")]
def score(group: tuple[str, str, str], rows: list[dict]) -> tuple:
module, tier, family = group
return (
PREFERRED_MODULES.get(module, 50),
FAMILY_ORDER.get(family, 20),
TIER_ORDER.get(tier, 9),
-sum(row.get("kind") in {"function", "method"} for row in rows),
-len(rows),
module,
tier,
family,
)
def compact_row(row: dict) -> list[object]:
return [row["id"], row["name"], row["disposition"], row.get("source"), row.get("sourceLine")]
def size_without_single_record_tail(total: int, desired: int, minimum: int, cap: int) -> int:
"""Keep a family tail attached to a module batch instead of isolating one API."""
selected = min(total, desired, cap)
remaining = total - selected
if remaining == 1 and selected > minimum:
selected -= 1
return max(1, selected)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--module", help="restrict selection to one source module")
parser.add_argument("--family", help="restrict selection to one adapter family")
parser.add_argument("--tier", help="restrict selection to one runtime tier")
parser.add_argument("--size", type=int, default=None, help="maximum records in one batch; omitted means adaptive (default starts at 24)")
parser.add_argument("--output", type=Path, default=OUTPUT)
args = parser.parse_args()
if implementation_first_enabled() and os.environ.get("FREECAD_ALLOW_LEGACY_API_PLANNER", "0").lower() not in {"1", "true", "yes", "on"}:
payload = {
"schema": "freecad-web-module-function-batch/v1",
"status": "no_ready_batch",
"generatedAt": datetime.now(timezone.utc).isoformat(),
"reason": "legacy API planner is disabled; use prepare_implementation_business_batch.py",
"records": [],
"queue": [],
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(json.dumps({"status": "no_ready_batch", "records": 0, "reason": payload["reason"]}, ensure_ascii=False))
return 0
records = []
records.extend(load_records(P6 / "python-api-manifest.json", "python"))
records.extend(load_records(P6 / "object-contract-matrix.json", "object"))
records.extend(load_records(P6 / "workbench-command-matrix.json", "workbench"))
records.extend(load_records(P6 / "format-coverage-kernel.json", "format"))
if implementation_first_enabled():
# Canonical exposed/passed/native_handler rows are already excluded by
# disposition. For unresolved rows, implementation receipts are the
# only reusable state; historical gate files are diagnostics and must
# not be rescanned or mistaken for completed business behavior.
covered_ids = all_implementation_ready_ids()
pending_ids: set[str] = set()
else:
covered_ids = completed_evidence_ids()
pending_ids = pending_evidence_ids() if os.environ.get("FREECAD_RETRY_EVIDENCED", "0").lower() not in {"1", "true", "yes", "on"} else set()
groups: dict[tuple[str, str, str], list[dict]] = defaultdict(list)
for row in records:
if row["disposition"] not in UNRESOLVED:
continue
if row.get("id") in covered_ids:
continue
if row.get("id") in pending_ids:
continue
if args.module and row["module"] != args.module:
continue
if args.family and row["family"] != args.family:
continue
if args.tier and row["tier"] != args.tier:
continue
groups[(row["module"], row["tier"], row["family"])].append(row)
for rows in groups.values():
rows.sort(key=lambda row: (row.get("source") or "", row.get("sourceLine") or 0, row["name"], row["id"]))
if not groups:
payload = {
"schema": "freecad-web-module-function-batch/v1",
"status": "no_ready_batch",
"generatedAt": datetime.now(timezone.utc).isoformat(),
"records": [],
"queue": [],
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(json.dumps({"status": payload["status"], "records": 0}, ensure_ascii=False))
# An empty queue is a valid terminal planner result. Returning a
# non-zero status makes the scheduler treat an exhausted module as a
# command failure even though the structured payload is complete.
return 0
ordered = sorted(groups.items(), key=lambda item: score(item[0], item[1]))
min_records = 2
try:
checkpoint = json.loads((ROOT / "docs/current/continuation-state.json").read_text(encoding="utf-8"))
min_records = max(1, int(checkpoint.get("batchPolicy", {}).get("minRecords", 2)))
except (OSError, json.JSONDecodeError, TypeError, ValueError):
pass
# Prefer a full-size group whenever one exists. Singleton groups are only
# selected after all remaining groups are smaller than the policy minimum;
# this prevents the scheduler from repeatedly stopping on a one-record
# tail while larger work is still available.
eligible = [item for item in ordered if len(item[1]) >= min_records]
singleton_fallback = not eligible
selection_pool = eligible or ordered
(module, tier, family), selected_rows = selection_pool[0]
size, size_reason = adaptive_target_size(args.size, tier, module, family)
if tier == "object-contract":
family_cap = 16
elif family in {"pure-function", "geometry-value"}:
family_cap = 32
else:
family_cap = 24
effective_size = min(size, family_cap)
# Part generic-object fixtures construct heavyweight OCCT 2D/3D wrapper
# objects. Keep their batch below the 32 MiB fast-dev initial heap so one
# malformed or unusually large record cannot exhaust the whole probe.
if module == "Part" and family == "generic-object":
effective_size = min(effective_size, 12)
effective_size = size_without_single_record_tail(
len(selected_rows), effective_size, min_records, family_cap
)
selected_rows = selected_rows[:effective_size]
counts = Counter(row["disposition"] for row in selected_rows)
identity = "|".join([module, tier, family, *(row["id"] for row in selected_rows)])
batch_id = hashlib.sha256(identity.encode("utf-8")).hexdigest()[:24]
queue = []
for (q_module, q_tier, q_family), q_rows in ordered[:12]:
queue.append(
{
"module": q_module,
"tier": q_tier,
"family": q_family,
"unresolved": len(q_rows),
"nextSize": min(effective_size, len(q_rows)),
}
)
state = json.loads(STATE.read_text(encoding="utf-8"))
module_groups = [
{"tier": q_tier, "family": q_family, "remaining": len(q_rows)}
for (q_module, q_tier, q_family), q_rows in ordered
if q_module == module
]
module_records = [row for row in records if row.get("module") == module]
module_reused = sum(
row.get("disposition") not in UNRESOLVED or str(row.get("id")) in covered_ids
for row in module_records
)
payload = {
"schema": "freecad-web-module-function-batch/v1",
"status": "ready",
"generatedAt": datetime.now(timezone.utc).isoformat(),
"taskId": state.get("active_task"),
"batchId": batch_id,
"strategy": "module-business-first-deferred-validation",
"businessBundle": {
"module": module,
"coverageRecords": sum(row["remaining"] for row in module_groups),
"families": module_groups,
"selectedRecordsAreCoverageAnchors": True,
"implementationUnit": "shared-module-dispatcher",
"reuse": {
"moduleInventoryRecords": len(module_records),
"moduleAlreadyImplementedRecords": module_reused,
"moduleRemainingRecords": len(module_records) - module_reused,
"globalImplementationReceipts": len(all_implementation_ready_ids()) if implementation_first_enabled() else 0,
},
},
"targetSize": effective_size,
"targetSizeReason": size_reason,
"wasmPolicy": {
"mode": "independent-module-artifact",
"dynamicLoad": True,
"mergeRequired": False,
"artifactFields": ["module", "worker", "revision", "runtimeUrl", "wasmUrl", "dataUrl", "assetBaseUrl"],
},
"minimumRecords": min_records,
"singletonFallback": singleton_fallback and len(selected_rows) < min_records,
"selected": {"module": module, "tier": tier, "family": family, "recordCount": len(selected_rows), "dispositionCounts": dict(counts)},
"fields": ["id", "name", "disposition", "source", "sourceLine"],
"records": [compact_row(row) for row in selected_rows],
"queue": queue,
"boundaries": [
"The batch planner never changes dispositions or task state.",
"Selected API/object rows are coverage anchors for one module business implementation, never individual implementation tasks.",
"One module dispatcher/family adapter is reused for the entire batch; no API-specific Worker, page, build or gate is created.",
"WASM artifacts remain split by functional module and are loaded on demand; no binary merge is required.",
"Implementation mode performs target build/sync/receipt only; validation remains globally deferred.",
],
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n", encoding="utf-8")
print(json.dumps({"status": payload["status"], "batchId": batch_id, "module": module, "tier": tier, "family": family, "records": len(selected_rows), "queue": queue[:4]}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())