553 lines
26 KiB
Python
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())
|