aps-agent/server/aps_domain/mps_planning.py

1905 lines
68 KiB
Python

# ============================================================
# Plan-layer MPS / S&OP (moduleId: domain-mps-planning, R71.4)
# Long-term master plan draft + finite rough-cut capacity check
# + plan -> schedule -> publish -> execution feedback trace.
# Read-only projections and lightweight trace storage only;
# existing PlanStore governance is reused, never mutated.
# ============================================================
from __future__ import annotations
import copy
import hashlib
import json
import os
import tempfile
import threading
import uuid
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from server.aps_domain.planning import WARN_RATIO, build_bucket_skeleton, daily_capacity
from server.timeutil import add_minutes, fmt_date, parse_dt, today0
World = dict[str, Any]
MPS_CAPACITY_MODES = {"FINITE", "INFINITE"}
MPS_LONG_CYCLE_THRESHOLD_DAYS = 30.0
_DRAFT_HASH_V1 = "mps-draft-hash.v1"
_DRAFT_HASH_V1_NUMERIC = "mps-draft-hash.v1-json-numbers"
_DRAFT_HASH_V2 = "mps-draft-hash.v2"
_DRAFT_BINDING_KEYS = frozenset(
{"draftHash", "draftHashVersion", "scope", "worldFingerprint"}
)
_MPS_DERIVED_WORLD_KEYS = frozenset(
{
"scheduleVersions",
"productionOrders",
"workOrders",
"conflicts",
"flexScheduleVersions",
"flexVirtualLines",
"flexWorkOrders",
"flexConflicts",
}
)
_SCHEDULE_LIFECYCLE_KEYS = frozenset(
{
"status",
"publishedAt",
"publishReady",
"dispatchReady",
"completedQuantity",
"progressPercent",
"actualStartTime",
"actualEndTime",
"reportedAt",
}
)
def _now_iso() -> str:
return datetime.now(UTC).isoformat().replace("+00:00", "Z")
def _canonical_hash(value: Any) -> str:
payload = json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
allow_nan=False,
default=str,
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def _scope_payload(
*,
tenant_uuid: str,
project_id: str,
world_key: str,
) -> dict[str, str]:
values = {
"tenantUuid": str(tenant_uuid or "").strip(),
"projectId": str(project_id or "").strip(),
"worldKey": str(world_key or "").strip(),
}
if not all(values.values()):
raise ValueError("MPS scope requires tenantUuid, projectId, and worldKey")
return values
def _draft_inputs(draft: dict[str, Any]) -> dict[str, Any]:
return {
"mode": str(draft.get("mode") or "WEEK").upper(),
"startDate": str(draft.get("startDate") or "")[:10],
"horizonDays": int(draft.get("horizonDays") or 0),
"includeForecast": bool(draft.get("includeForecast", True)),
"includeFirm": bool(draft.get("includeFirm", True)),
"longCycleOnly": bool(draft.get("longCycleOnly", False)),
"capacityMode": str(draft.get("capacityMode") or "FINITE").upper(),
}
def _draft_hash(draft: dict[str, Any]) -> str:
version = str(draft.get("draftHashVersion") or _DRAFT_HASH_V1)
return _draft_hash_for_version(draft, version)
def _draft_hash_for_version(draft: dict[str, Any], version: str) -> str:
payload = {
key: value
for key, value in copy.deepcopy(draft).items()
if key not in _DRAFT_BINDING_KEYS and key != "planId"
}
if version == _DRAFT_HASH_V1:
return _canonical_hash(payload)
if version == _DRAFT_HASH_V1_NUMERIC:
return _canonical_hash(_normalize_json_numbers_v1(payload))
if version == _DRAFT_HASH_V2:
return _canonical_hash(_typed_json_tree(payload))
raise ValueError(f"unsupported MPS draft hash version: {version}")
def _resolve_draft_hash_version(draft: dict[str, Any]) -> str:
"""Resolve explicit v2 or either unversioned historical hash algorithm."""
supplied_hash = str(draft.get("draftHash") or "")
explicit = str(draft.get("draftHashVersion") or "")
if explicit:
if explicit not in {_DRAFT_HASH_V1, _DRAFT_HASH_V1_NUMERIC, _DRAFT_HASH_V2}:
raise ValueError("MPS draft hash version is not supported")
if supplied_hash != _draft_hash_for_version(draft, explicit):
raise ValueError("MPS draft content hash mismatch")
return explicit
for legacy in (_DRAFT_HASH_V1, _DRAFT_HASH_V1_NUMERIC):
if supplied_hash == _draft_hash_for_version(draft, legacy):
return legacy
raise ValueError("MPS draft content hash mismatch")
def _json_number_text(value: int | float | Decimal) -> str:
"""Return one decimal representation for the same JSON numeric value."""
number = Decimal(str(value))
if not number.is_finite():
raise ValueError("MPS draft contains a non-finite number")
text = format(number.normalize(), "f")
if "." in text:
text = text.rstrip("0").rstrip(".")
return "0" if text in {"", "-0"} else text
def _normalize_json_numbers_v1(value: Any) -> Any:
"""Exact interim unversioned algorithm kept only for trace migration."""
if value is None or isinstance(value, (str, bool)):
return value
if isinstance(value, (int, float, Decimal)):
return {"__apsJsonNumber__": _json_number_text(value)}
if isinstance(value, dict):
return {str(key): _normalize_json_numbers_v1(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_normalize_json_numbers_v1(item) for item in value]
return str(value)
def _typed_json_tree(value: Any) -> Any:
"""Encode every JSON type so internal hash nodes cannot collide with business data."""
if value is None:
return ["null"]
if isinstance(value, bool):
return ["bool", value]
if isinstance(value, (int, float, Decimal)):
return ["number", _json_number_text(value)]
if isinstance(value, str):
return ["string", value]
if isinstance(value, dict):
items: dict[str, Any] = {}
for key, item in value.items():
string_key = str(key)
if string_key in items:
raise ValueError(f"MPS draft has duplicate JSON object key: {string_key}")
items[string_key] = _typed_json_tree(item)
return ["object", [[key, items[key]] for key in sorted(items)]]
if isinstance(value, (list, tuple)):
return ["array", [_typed_json_tree(item) for item in value]]
return ["stringified", type(value).__qualname__, str(value)]
def _world_fingerprint(world: World) -> str:
from server.agent_core.harness import world_fingerprint
planning_inputs = {
key: value
for key, value in (world or {}).items()
if key not in _MPS_DERIVED_WORLD_KEYS
}
return world_fingerprint(planning_inputs)
def bind_mps_draft(
world: World,
draft: dict[str, Any],
*,
tenant_uuid: str,
project_id: str,
world_key: str,
) -> dict[str, Any]:
"""Bind a server-generated draft to one tenant/project world snapshot."""
bound = copy.deepcopy(draft)
bound["scope"] = _scope_payload(
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
)
bound["worldFingerprint"] = _world_fingerprint(world)
bound["draftHashVersion"] = _DRAFT_HASH_V2
bound["draftHash"] = _draft_hash(bound)
return bound
def validate_mps_draft_binding(
world: World,
draft: dict[str, Any],
*,
tenant_uuid: str,
project_id: str,
world_key: str,
allow_legacy: bool = False,
) -> dict[str, Any]:
"""Fail closed unless a draft is reproducible in the current scoped world."""
if not isinstance(draft, dict) or draft.get("kind") != "MPS_DRAFT":
raise ValueError("MPS draft payload is invalid")
expected_scope = _scope_payload(
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
)
if draft.get("scope") != expected_scope:
raise ValueError("MPS draft scope does not match the authenticated project")
current_world_fingerprint = _world_fingerprint(world)
if draft.get("worldFingerprint") != current_world_fingerprint:
raise ValueError("MPS draft world fingerprint is stale")
supplied_hash = str(draft.get("draftHash") or "")
hash_version = _resolve_draft_hash_version(draft)
if hash_version != _DRAFT_HASH_V2 and not allow_legacy:
raise ValueError("MPS draft persist/gap requires draftHashVersion v2")
inputs = _draft_inputs(draft)
rebuilt = build_mps_draft(
world,
mode=inputs["mode"],
start_date=inputs["startDate"],
horizon_days=inputs["horizonDays"],
include_forecast=inputs["includeForecast"],
include_firm=inputs["includeFirm"],
long_cycle_only=inputs["longCycleOnly"],
capacity_mode=inputs["capacityMode"],
)
rebuilt["draftId"] = draft.get("draftId")
if draft.get("draftHashVersion"):
rebuilt["draftHashVersion"] = hash_version
if _draft_hash_for_version(rebuilt, hash_version) != supplied_hash:
raise ValueError("MPS draft cannot be reproduced from the current world")
return copy.deepcopy(draft)
def _input_summary(
draft: dict[str, Any],
*,
tenant_uuid: str,
project_id: str,
world_key: str,
) -> dict[str, Any]:
return {
**_scope_payload(
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
),
"draftId": draft.get("draftId"),
"draftHash": draft.get("draftHash"),
"draftHashVersion": _resolve_draft_hash_version(draft),
"worldFingerprint": draft.get("worldFingerprint"),
"parameters": _draft_inputs(draft),
}
def _as_date(value: str) -> Any:
return parse_dt(str(value)[:10] + " 00:00")
def _shift_minutes(
start_time: str, end_time: str, breaks: list[dict[str, Any]]
) -> float:
try:
start = parse_dt(f"2000-01-01 {start_time}")
end = parse_dt(f"2000-01-01 {end_time}")
minutes = (end - start).total_seconds() / 60.0
for item in breaks or []:
b0 = parse_dt(f"2000-01-01 {item.get('start')}")
b1 = parse_dt(f"2000-01-01 {item.get('end')}")
minutes -= (b1 - b0).total_seconds() / 60.0
return max(float(minutes), 60.0)
except (TypeError, ValueError):
return 480.0
def _calendar_context(world: World) -> dict[str, Any]:
flex = world.get("flexCalendar") or []
if flex:
cal = flex[0]
return {
"source": "flexCalendar",
"workdays": list(cal.get("workdays") or [1, 2, 3, 4, 5]),
"shiftMinutes": _shift_minutes(
str(cal.get("startTime") or "08:00"),
str(cal.get("endTime") or "17:00"),
list(cal.get("breaks") or []),
),
}
shift_rows = world.get("shiftCalendar") or []
if shift_rows:
working_dates = sorted(
{
str(row["date"])
for row in shift_rows
if row.get("isWorking") is not False
}
)
total = 0.0
for shift in world.get("shifts") or []:
if str(shift.get("status") or "ACTIVE").upper() not in ("ACTIVE",):
continue
total += _shift_minutes(
str(shift.get("startTime") or "08:00"),
str(shift.get("endTime") or "17:00"),
list(shift.get("breakPeriods") or []),
)
return {
"source": "shiftCalendar",
"workingDates": working_dates,
"shiftMinutes": max(total, 60.0),
}
return {
"source": "default",
"workdays": [1, 2, 3, 4, 5],
"shiftMinutes": 480.0,
}
def _is_workday(cal: dict[str, Any], date_s: str) -> bool:
if cal.get("source") == "shiftCalendar":
return date_s in set(cal.get("workingDates") or [])
try:
return parse_dt(date_s + " 00:00").isoweekday() in set(
cal.get("workdays") or [1, 2, 3, 4, 5]
)
except (TypeError, ValueError):
return True
def _bucket_workdays(cal: dict[str, Any], bucket: dict[str, Any]) -> int:
start = _as_date(bucket["start"])
end = _as_date(bucket["end"])
count = 0
current = start
while current <= end:
if _is_workday(cal, fmt_date(current)):
count += 1
current = add_minutes(current, 24 * 60)
return count
def _order_lead_time_days(order: dict[str, Any]) -> float:
raw = order.get("leadTimeDays")
if raw is not None:
try:
return float(raw)
except (TypeError, ValueError):
pass
values = [item.get("leadTimeDays") for item in order.get("items") or []]
parsed = [float(v) for v in values if v is not None]
return max(parsed) if parsed else 0.0
def _collect_mps_demands(
world: World,
*,
include_forecast: bool,
include_firm: bool,
long_cycle_only: bool,
) -> list[dict[str, Any]]:
from server.aps_domain.forecast import ensure_forecast_table
from server.aps_domain.orders import SCHEDULABLE_STATUSES
points: list[dict[str, Any]] = []
if include_firm:
for so in world.get("salesOrders") or []:
if so.get("status") not in SCHEDULABLE_STATUSES:
continue
due = str(so.get("deliveryDate") or "")[:10]
if not due:
continue
is_long = (
bool(so.get("isLongCycle"))
or _order_lead_time_days(so) >= MPS_LONG_CYCLE_THRESHOLD_DAYS
)
if long_cycle_only and not is_long:
continue
for item in so.get("items") or []:
if item.get("status") in ("CANCELLED", "COMPLETED"):
continue
qty = int(item.get("quantity") or 0)
if qty <= 0:
continue
points.append(
{
"source": "LONG_CYCLE" if is_long else "FIRM",
"ref": so.get("orderNo"),
"productId": item.get("productId"),
"productCode": item.get("productCode"),
"productName": item.get("productName"),
"quantity": qty,
"weightedQty": float(qty),
"dueDate": due,
"leadTimeDays": _order_lead_time_days(so),
}
)
if include_forecast:
ensure_forecast_table(world)
for fc in world.get("forecastOrders") or []:
if fc.get("status") not in ("ACTIVE",):
continue
due = str(fc.get("dueDate") or fc.get("periodEnd") or "")[:10]
if not due:
continue
qty = int(fc.get("quantity") or 0)
if qty <= 0:
continue
confidence = float(
fc.get("confidence") if fc.get("confidence") is not None else 1.0
)
confidence = max(0.0, min(1.0, confidence))
points.append(
{
"source": "FORECAST",
"ref": fc.get("forecastNo"),
"productId": fc.get("productId"),
"productCode": fc.get("productCode"),
"productName": fc.get("productName"),
"quantity": qty,
"weightedQty": round(qty * confidence, 1),
"dueDate": due,
"confidence": confidence,
"leadTimeDays": 0,
}
)
return points
def _bucket_for_date(
buckets: list[dict[str, Any]], date_s: str
) -> dict[str, Any] | None:
if not buckets:
return None
if date_s < buckets[0]["start"]:
return buckets[0]
for bucket in buckets:
if bucket["start"] <= date_s <= bucket["end"]:
return bucket
return None
def _status_for(ratio: float) -> str:
if ratio >= 1.0:
return "OVER"
if ratio >= WARN_RATIO:
return "WARN"
return "OK"
def _commitment(status: str) -> str:
if status == "OVER":
return "INFEASIBLE"
if status == "WARN":
return "AT_RISK"
return "FEASIBLE"
def assess_mps_capacity(world: World, draft: dict[str, Any]) -> dict[str, Any]:
"""Rough-cut finite capacity assessment for an MPS draft."""
buckets = draft.get("buckets") or []
total_weighted = sum(float(b.get("weightedDemand") or 0) for b in buckets)
feasible_weighted = sum(
float(b.get("weightedDemand") or 0)
for b in buckets
if b.get("status") != "OVER"
)
over = sum(1 for b in buckets if b.get("status") == "OVER")
warn = sum(1 for b in buckets if b.get("status") == "WARN")
bottleneck_buckets = sorted(
[b for b in buckets if b.get("status") == "OVER"],
key=lambda b: float(b.get("loadRatio") or 0),
reverse=True,
)
bottlenecks: list[dict[str, Any]] = []
for bucket in bottleneck_buckets[:5]:
drivers = [
{
"productCode": item.get("productCode"),
"productName": item.get("productName"),
"weightedQty": item.get("weightedQty"),
}
for item in (bucket.get("byProduct") or [])[:3]
]
workdays = int(bucket.get("workdays") or 1)
bottlenecks.append(
{
"key": bucket.get("key"),
"label": bucket.get("label"),
"loadRatio": bucket.get("loadRatio"),
"gap": bucket.get("gap"),
"requiredDaily": round(
float(bucket.get("weightedDemand") or 0) / workdays, 1
),
"drivers": drivers,
}
)
peak_load = max((float(b.get("loadRatio") or 0) for b in buckets), default=0.0)
feasible_rate = (
round(feasible_weighted / total_weighted, 3) if total_weighted else 1.0
)
feasible_bucket_rate = (
round((len(buckets) - over) / len(buckets), 3) if buckets else 1.0
)
if over:
first = bottlenecks[0]
hint = (
f"Finite capacity OVER in {over} bucket(s); earliest bottleneck {first.get('label')} "
f"at load {first.get('loadRatio')}. Review leveling/outsourcing before trial scheduling."
)
elif warn:
hint = (
f"No overloaded bucket, but {warn} bucket(s) at or above {WARN_RATIO:.0%} load. "
"Monitor the warning window before commit."
)
else:
hint = "Finite rough-cut capacity is feasible across the MPS horizon."
return {
"capacityMode": draft.get("capacityMode") or "FINITE",
"dailyCapacity": draft.get("dailyCapacity") or daily_capacity(world),
"feasibleRate": feasible_rate,
"feasibleBucketRate": feasible_bucket_rate,
"overCount": over,
"warnCount": warn,
"okCount": len(buckets) - over - warn,
"peakLoadRatio": round(peak_load, 3),
"bottleneckBucketCount": len(bottleneck_buckets),
"bottlenecks": bottlenecks,
"hint": hint,
}
def build_mps_draft(
world: World,
*,
mode: str = "WEEK",
start_date: str | None = None,
horizon_days: int = 90,
include_forecast: bool = True,
include_firm: bool = True,
long_cycle_only: bool = False,
capacity_mode: str = "FINITE",
) -> dict[str, Any]:
"""Build a bucketed long-term MPS draft from forecasts and firm/long-cycle orders."""
cap_mode = (capacity_mode or "FINITE").upper()
if cap_mode not in MPS_CAPACITY_MODES:
raise ValueError("capacityMode must be FINITE or INFINITE")
skeleton = build_bucket_skeleton(
mode=mode, start_date=start_date, horizon_days=horizon_days
)
calendar = _calendar_context(world)
shift_minutes = float(calendar.get("shiftMinutes") or 480.0)
day_cap = daily_capacity(world)
points = _collect_mps_demands(
world,
include_forecast=include_forecast,
include_firm=include_firm,
long_cycle_only=long_cycle_only,
)
buckets: list[dict[str, Any]] = []
for raw in skeleton:
workdays = _bucket_workdays(calendar, raw)
capacity = round(day_cap * workdays, 1) if cap_mode == "FINITE" else None
buckets.append(
{
**raw,
"workdays": workdays,
"workHours": round(workdays * shift_minutes / 60.0, 1),
"capacityMode": cap_mode,
"capacity": capacity,
"firmQty": 0,
"forecastQty": 0,
"demandQty": 0,
"weightedDemand": 0.0,
"loadRatio": 0.0,
"gap": 0.0,
"status": "OK",
"byProduct": {},
"mpsLines": [],
}
)
unbucketed: list[dict[str, Any]] = []
line_no = 0
for point in points:
bucket = _bucket_for_date(buckets, point["dueDate"])
if bucket is None:
unbucketed.append(
{
**point,
"planLineId": f"ML-UNBUCKETED-{len(unbucketed) + 1:03d}",
"reason": "OUT_OF_HORIZON",
}
)
continue
line_no += 1
line = {
"planLineId": f"ML-{bucket['key']}-{line_no:03d}",
"source": point["source"],
"ref": point["ref"],
"productId": point["productId"],
"productCode": point["productCode"],
"productName": point["productName"],
"quantity": point["quantity"],
"weightedQty": point["weightedQty"],
"dueDate": point["dueDate"],
"bucketKey": bucket["key"],
"recommendedStartDate": bucket["start"],
"plannedOrderQty": point["quantity"],
"leadTimeDays": round(float(point.get("leadTimeDays") or 0)),
}
bucket["mpsLines"].append(line)
bucket["demandQty"] += point["quantity"]
bucket["weightedDemand"] += point["weightedQty"]
if point["source"] == "FORECAST":
bucket["forecastQty"] += point["quantity"]
else:
bucket["firmQty"] += point["quantity"]
product = bucket["byProduct"].setdefault(
point["productCode"],
{
"productCode": point["productCode"],
"productName": point["productName"],
"firmQty": 0,
"forecastQty": 0,
"weightedQty": 0.0,
"lineCount": 0,
},
)
product["weightedQty"] = round(product["weightedQty"] + point["weightedQty"], 1)
product["lineCount"] += 1
if point["source"] == "FORECAST":
product["forecastQty"] += point["quantity"]
else:
product["firmQty"] += point["quantity"]
for bucket in buckets:
if cap_mode == "FINITE" and bucket["capacity"] and bucket["capacity"] > 0:
ratio = round(
float(bucket["weightedDemand"]) / float(bucket["capacity"]), 3
)
gap = round(float(bucket["weightedDemand"]) - float(bucket["capacity"]), 1)
else:
ratio, gap = 0.0, 0.0
status = _status_for(ratio) if cap_mode == "FINITE" else "OK"
bucket["loadRatio"] = ratio
bucket["gap"] = gap
bucket["status"] = status
bucket["byProduct"] = sorted(
bucket["byProduct"].values(),
key=lambda item: -(float(item["firmQty"]) + float(item["forecastQty"])),
)
for line in bucket["mpsLines"]:
line["commitment"] = _commitment(status)
line["commitmentReason"] = f"bucket status {status}"
total_firm = sum(int(b["firmQty"]) for b in buckets)
total_forecast = sum(int(b["forecastQty"]) for b in buckets)
total_weighted = sum(float(b["weightedDemand"]) for b in buckets)
total_capacity = sum(float(b["capacity"] or 0) for b in buckets)
over_count = sum(1 for b in buckets if b["status"] == "OVER")
warn_count = sum(1 for b in buckets if b["status"] == "WARN")
line_count = sum(len(b["mpsLines"]) for b in buckets)
feasible_lines = sum(
1
for b in buckets
for line in b["mpsLines"]
if line["commitment"] != "INFEASIBLE"
)
draft = {
"kind": "MPS_DRAFT",
"draftId": f"MPS-{fmt_date(today0()).replace('-', '')}-{uuid.uuid4().hex[:8].upper()}",
"planId": None,
"mode": (mode or "WEEK").upper(),
"capacityMode": cap_mode,
"startDate": skeleton[0]["start"] if skeleton else fmt_date(today0()),
"endDate": skeleton[-1]["end"] if skeleton else fmt_date(today0()),
"horizonDays": int(horizon_days),
"includeForecast": include_forecast,
"includeFirm": include_firm,
"longCycleOnly": long_cycle_only,
"dailyCapacity": day_cap,
"warnThreshold": WARN_RATIO,
"calendar": {
"source": calendar["source"],
"shiftMinutes": round(shift_minutes),
"workdays": calendar.get("workdays")
or len(calendar.get("workingDates") or []),
},
"summary": {
"bucketCount": len(buckets),
"overCount": over_count,
"warnCount": warn_count,
"okCount": len(buckets) - over_count - warn_count,
"firmQty": total_firm,
"forecastQty": total_forecast,
"demandQty": total_firm + total_forecast,
"weightedDemand": round(total_weighted, 1),
"capacity": round(total_capacity, 1) if cap_mode == "FINITE" else None,
"overallLoad": round(total_weighted / total_capacity, 3)
if total_capacity
else 0.0,
"lineCount": line_count,
"feasibleLineCount": feasible_lines,
"infeasibleLineCount": line_count - feasible_lines,
"unbucketedCount": len(unbucketed),
},
"buckets": buckets,
"unbucketed": unbucketed,
}
draft["capacity"] = assess_mps_capacity(world, draft)
if cap_mode == "INFINITE":
draft["hint"] = (
"INFINITE rough-cut capacity: demand is not capped by line capacity; "
"finite reference is available in assess_mps_capacity."
)
else:
draft["hint"] = (
"MPS draft uses the shift calendar for workdays and ACTIVE line daily capacity; "
"OVER buckets need leveling/outsourcing before trial scheduling."
)
return draft
class MpsTraceStore:
"""Append-only lightweight envelope journal for MPS -> schedule -> publish -> feedback."""
schema_version = "2.0"
def __init__(
self,
path: str,
*,
tenant_uuid: str,
project_id: str,
world_key: str,
) -> None:
self.path = path
self.scope = _scope_payload(
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
)
self._lock = threading.RLock()
self._traces = self._load()
def _load(self) -> dict[str, dict[str, Any]]:
try:
with open(self.path, "r", encoding="utf-8") as handle:
document = json.load(handle)
except FileNotFoundError:
return {}
except (json.JSONDecodeError, OSError) as exc:
raise ValueError(f"cannot read MPS trace store: {exc}") from exc
if not isinstance(document, dict):
raise TypeError("MPS trace store document must be a JSON object")
traces = document.get("traces")
if traces is None:
return {}
if not isinstance(traces, dict):
raise TypeError("MPS trace store traces must be a JSON object")
return copy.deepcopy(traces)
def _write(self) -> None:
directory = os.path.dirname(self.path) or "."
os.makedirs(directory, exist_ok=True)
fd, temporary = tempfile.mkstemp(dir=directory, suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(
{"schemaVersion": self.schema_version, "traces": self._traces},
handle,
ensure_ascii=False,
)
os.replace(temporary, self.path)
except BaseException:
if os.path.exists(temporary):
os.unlink(temporary)
raise
def create(
self,
*,
plan_id: str,
plan_version: int | None = None,
draft_id: str | None = None,
draft_snapshot: dict[str, Any],
input_summary: dict[str, Any],
world_fingerprint: str,
trace_id: str | None = None,
created_by: str = "SYSTEM",
) -> dict[str, Any]:
with self._lock:
if any(
input_summary.get(key) != value for key, value in self.scope.items()
):
raise ValueError(
"MPS trace input summary scope does not match its store"
)
if draft_snapshot.get("scope") != self.scope:
raise ValueError("MPS trace draft scope does not match its store")
if draft_snapshot.get("draftHash") != input_summary.get("draftHash"):
raise ValueError(
"MPS trace draft hash does not match its input summary"
)
draft_hash_version = _resolve_draft_hash_version(draft_snapshot)
summary_hash_version = str(
input_summary.get("draftHashVersion") or draft_hash_version
)
if draft_hash_version != summary_hash_version:
raise ValueError(
"MPS trace draft hash version does not match its input summary"
)
if world_fingerprint != input_summary.get("worldFingerprint"):
raise ValueError(
"MPS trace world fingerprint does not match its input summary"
)
resolved_id = trace_id or f"TR-{uuid.uuid4().hex[:12].upper()}"
if resolved_id in self._traces:
raise ValueError(f"MPS trace {resolved_id!r} already exists")
now = _now_iso()
envelope: dict[str, Any] = {
"traceId": resolved_id,
"planId": plan_id,
"planVersion": plan_version,
"draftId": draft_id,
**self.scope,
"draftHash": input_summary.get("draftHash"),
"draftHashVersion": summary_hash_version,
"worldFingerprint": world_fingerprint,
"inputSummary": copy.deepcopy(input_summary),
"draftSnapshot": copy.deepcopy(draft_snapshot),
"scheduleId": None,
"scheduleVersionNo": None,
"scheduleTrack": None,
"scheduleFingerprint": None,
"scheduleStatusAtLink": None,
"scheduleLinkedAt": None,
"scheduleLinkedBy": None,
"adoptId": None,
"adoptedAt": None,
"adoptedBy": None,
"publishId": None,
"publishedAt": None,
"publishedBy": None,
"executionFeedback": [],
"events": [{"at": now, "event": "OPEN", "by": created_by}],
"createdAt": now,
"updatedAt": now,
}
self._traces[resolved_id] = copy.deepcopy(envelope)
try:
self._write()
except BaseException:
self._traces.pop(resolved_id, None)
raise
return copy.deepcopy(envelope)
def discard_uncommitted(self, trace_id: str) -> None:
"""Compensate a freshly opened trace before its Plan node commits."""
with self._lock:
envelope = self._require(trace_id)
events = envelope.get("events") or []
if (
len(events) != 1
or events[0].get("event") != "OPEN"
or envelope.get("scheduleId") is not None
or envelope.get("publishId") is not None
or envelope.get("executionFeedback")
):
raise ValueError("only an uncommitted OPEN MPS trace can be discarded")
previous = copy.deepcopy(envelope)
del self._traces[trace_id]
try:
self._write()
except BaseException:
self._traces[trace_id] = previous
raise
def _require(self, trace_id: str) -> dict[str, Any]:
envelope = self._traces.get(trace_id)
if envelope is None or any(
envelope.get(key) != value for key, value in self.scope.items()
):
raise ValueError(f"MPS trace {trace_id!r} does not exist")
return envelope
def get(self, trace_id: str) -> dict[str, Any]:
with self._lock:
return copy.deepcopy(self._require(trace_id))
def list(self) -> list[dict[str, Any]]:
with self._lock:
rows = [
copy.deepcopy(value)
for value in self._traces.values()
if all(
value.get(key) == expected for key, expected in self.scope.items()
)
]
rows.sort(key=lambda row: row.get("createdAt") or "")
return rows
def replay(self, trace_id: str, *, world: World) -> dict[str, Any]:
envelope = self.get(trace_id)
draft = validate_mps_draft_binding(
world,
envelope.get("draftSnapshot") or {},
tenant_uuid=self.scope["tenantUuid"],
project_id=self.scope["projectId"],
world_key=self.scope["worldKey"],
allow_legacy=True,
)
summary = envelope.get("inputSummary") or {}
if summary.get("draftHash") != draft.get("draftHash"):
raise ValueError(
"MPS trace input summary does not match its draft snapshot"
)
return {
"traceId": envelope["traceId"],
"planId": envelope["planId"],
"planVersion": envelope.get("planVersion"),
"inputSummary": copy.deepcopy(summary),
"draft": draft,
}
def link_schedule(
self,
trace_id: str,
schedule_id: Any,
*,
schedule_version_no: str | None = None,
track: str = "fixed",
schedule_fingerprint: str | None = None,
schedule_status: str | None = None,
world_fingerprint: str | None = None,
actor: str = "SYSTEM",
_persist: bool = True,
) -> dict[str, Any]:
with self._lock:
envelope = self._require(trace_id)
normalized_track = str(track or "").lower()
if normalized_track not in {"fixed", "flex"}:
raise ValueError("MPS schedule track must be fixed or flex")
if schedule_id is None:
raise ValueError("MPS schedule id is required")
if (
world_fingerprint is not None
and envelope.get("worldFingerprint") != world_fingerprint
):
raise ValueError("MPS schedule world fingerprint does not match its trace")
existing_id = envelope.get("scheduleId")
if existing_id is not None:
previous = copy.deepcopy(envelope)
expected = {
"scheduleId": schedule_id,
"scheduleVersionNo": schedule_version_no,
"scheduleTrack": normalized_track,
"scheduleFingerprint": schedule_fingerprint,
}
mismatched = [
key
for key, value in expected.items()
if value is not None
and envelope.get(key) is not None
and (
not _same_id(envelope.get(key), value)
if key == "scheduleId"
else envelope.get(key) != value
)
]
if mismatched:
raise ValueError(
"MPS trace already links a different schedule version"
)
backfilled = False
for key, value in expected.items():
if envelope.get(key) is None and value is not None:
envelope[key] = value
backfilled = True
if schedule_status and envelope.get("scheduleStatusAtLink") is None:
envelope["scheduleStatusAtLink"] = schedule_status
backfilled = True
if not backfilled:
return copy.deepcopy(envelope)
now = _now_iso()
envelope["updatedAt"] = now
try:
if _persist:
self._write()
except BaseException:
self._traces[trace_id] = previous
raise
return copy.deepcopy(envelope)
previous = copy.deepcopy(envelope)
now = _now_iso()
envelope["scheduleId"] = schedule_id
envelope["scheduleVersionNo"] = schedule_version_no
envelope["scheduleTrack"] = normalized_track
envelope["scheduleFingerprint"] = schedule_fingerprint
envelope["scheduleStatusAtLink"] = schedule_status
envelope["scheduleLinkedAt"] = now
envelope["scheduleLinkedBy"] = actor
envelope["updatedAt"] = now
envelope["events"].append(
{
"at": now,
"event": "SCHEDULE",
"by": actor,
"detail": {
"scheduleId": schedule_id,
"scheduleVersionNo": schedule_version_no,
"track": envelope["scheduleTrack"],
"scheduleFingerprint": schedule_fingerprint,
},
}
)
try:
if _persist:
self._write()
except BaseException:
self._traces[trace_id] = previous
raise
return copy.deepcopy(envelope)
def link_publish(
self,
trace_id: str,
publish_id: Any,
*,
published_at: str | None = None,
fact_type: str = "PUBLISH",
schedule_id: Any = None,
schedule_version_no: str | None = None,
track: str | None = None,
schedule_fingerprint: str | None = None,
actor: str = "SYSTEM",
_persist: bool = True,
) -> dict[str, Any]:
with self._lock:
envelope = self._require(trace_id)
normalized_fact = str(fact_type or "PUBLISH").upper()
if normalized_fact not in {"ADOPT", "PUBLISH"}:
raise ValueError("MPS local fact must be ADOPT or PUBLISH")
if publish_id in (None, ""):
raise ValueError("MPS local fact id is required")
expected_track = str(track).lower() if track is not None else None
expected = {
"scheduleId": schedule_id,
"scheduleVersionNo": schedule_version_no,
"scheduleTrack": expected_track,
"scheduleFingerprint": schedule_fingerprint,
}
for key, value in expected.items():
if value is None:
continue
current = envelope.get(key)
matches = (
_same_id(current, value) if key == "scheduleId" else current == value
)
if current is None or not matches:
raise ValueError(
"MPS local fact does not match the linked schedule version"
)
id_key = "adoptId" if normalized_fact == "ADOPT" else "publishId"
at_key = "adoptedAt" if normalized_fact == "ADOPT" else "publishedAt"
by_key = "adoptedBy" if normalized_fact == "ADOPT" else "publishedBy"
existing_fact_id = envelope.get(id_key)
if existing_fact_id is not None:
if str(existing_fact_id) != str(publish_id):
raise ValueError(
f"MPS trace already records a different {normalized_fact} fact"
)
return copy.deepcopy(envelope)
previous = copy.deepcopy(envelope)
now = _now_iso()
envelope[id_key] = publish_id
envelope[at_key] = published_at or now
envelope[by_key] = actor
envelope["updatedAt"] = now
envelope["events"].append(
{
"at": now,
"event": normalized_fact,
"by": actor,
"detail": {
"factId": publish_id,
"factType": normalized_fact,
"scheduleId": envelope.get("scheduleId"),
"scheduleVersionNo": envelope.get("scheduleVersionNo"),
"track": envelope.get("scheduleTrack"),
"scheduleFingerprint": envelope.get("scheduleFingerprint"),
},
}
)
try:
if _persist:
self._write()
except BaseException:
self._traces[trace_id] = previous
raise
return copy.deepcopy(envelope)
def link_local_fact(
self,
trace_id: str,
*,
schedule_id: Any,
schedule_version_no: str,
track: str,
schedule_fingerprint: str,
schedule_status: str,
world_fingerprint: str,
fact_type: str,
fact_id: str,
fact_at: str | None = None,
actor: str = "SYSTEM",
) -> dict[str, Any]:
"""Atomically bind one local version and its adoption/publication fact."""
with self._lock:
previous = copy.deepcopy(self._require(trace_id))
try:
self.link_schedule(
trace_id,
schedule_id,
schedule_version_no=schedule_version_no,
track=track,
schedule_fingerprint=schedule_fingerprint,
schedule_status=schedule_status,
world_fingerprint=world_fingerprint,
actor=actor,
_persist=False,
)
self.link_publish(
trace_id,
fact_id,
published_at=fact_at,
fact_type=fact_type,
schedule_id=schedule_id,
schedule_version_no=schedule_version_no,
track=track,
schedule_fingerprint=schedule_fingerprint,
actor=actor,
_persist=False,
)
envelope = self._require(trace_id)
idempotent = envelope == previous
if not idempotent:
self._write()
return {
"trace": copy.deepcopy(envelope),
"idempotent": idempotent,
}
except BaseException:
self._traces[trace_id] = previous
raise
def link_feedback(
self,
trace_id: str,
*,
feedback_id: str,
work_order_id: Any,
progress_pct: int,
status: str,
qty_done: Any = None,
reported_at: str | None = None,
actor: str = "SYSTEM",
) -> dict[str, Any]:
with self._lock:
envelope = self._require(trace_id)
now = _now_iso()
feedback = {
"feedbackId": feedback_id,
"workOrderId": work_order_id,
"progressPct": max(0, min(100, int(progress_pct))),
"status": status,
"qtyDone": qty_done,
"reportedAt": reported_at or now,
"by": actor,
}
envelope["executionFeedback"].append(feedback)
envelope["updatedAt"] = now
envelope["events"].append(
{
"at": now,
"event": "FEEDBACK",
"by": actor,
"detail": {"feedbackId": feedback_id, "workOrderId": work_order_id},
}
)
self._write()
return copy.deepcopy(envelope)
def create_mps_plan_node(
draft: dict[str, Any],
*,
plan_store: Any,
input_summary: dict[str, Any],
plan_id: str | None = None,
created_by: str = "SYSTEM",
) -> Any:
"""Persist an MPS draft as an immutable L2 Plan node under a reusable L0/L1 chain."""
from server.agent_core.plan_runtime import PlanNotFoundError
l0_id = "intent-mps"
l1_id = "strategy-mps"
try:
plan_store.latest(l0_id)
except PlanNotFoundError:
plan_store.create(
plan_id=l0_id,
layer="L0",
parent_id=None,
inputs={"intent": "mps"},
payload={"kind": "intent", "action": "mps.plan"},
status="APPROVED",
created_by="SYSTEM",
)
try:
plan_store.latest(l1_id)
except PlanNotFoundError:
plan_store.create(
plan_id=l1_id,
layer="L1",
parent_id=l0_id,
inputs={"intent": l0_id},
payload={"kind": "strategy", "action": "mps.plan"},
status="APPROVED",
created_by="SYSTEM",
)
summary = draft.get("summary") or {}
capacity = draft.get("capacity") or {}
node = plan_store.create(
plan_id=plan_id or f"mps-{uuid.uuid4().hex[:12]}",
layer="L2",
parent_id=l1_id,
inputs=copy.deepcopy(input_summary),
payload={
"kind": "mps-draft",
"draftId": draft.get("draftId"),
"draftHash": draft.get("draftHash"),
"worldFingerprint": draft.get("worldFingerprint"),
"inputSummary": copy.deepcopy(input_summary),
"draftSnapshot": copy.deepcopy(draft),
"summary": {
"bucketCount": summary.get("bucketCount"),
"weightedDemand": summary.get("weightedDemand"),
"overCount": summary.get("overCount"),
"warnCount": summary.get("warnCount"),
"overallLoad": summary.get("overallLoad"),
"feasibleLineCount": summary.get("feasibleLineCount"),
},
"capacity": {
"feasibleRate": capacity.get("feasibleRate"),
"peakLoadRatio": capacity.get("peakLoadRatio"),
"bottleneckBucketCount": capacity.get("bottleneckBucketCount"),
},
"bucketSummary": [
{
"key": b.get("key"),
"label": b.get("label"),
"status": b.get("status"),
"loadRatio": b.get("loadRatio"),
"gap": b.get("gap"),
}
for b in (draft.get("buckets") or [])
],
},
status="DRAFT",
evidence_refs=[f"mps-draft:{draft.get('draftId')}"],
created_by=created_by,
)
return node
def create_mps_trace(
plan_node: Any,
*,
trace_store: MpsTraceStore,
draft: dict[str, Any],
input_summary: dict[str, Any],
trace_id: str | None = None,
created_by: str = "SYSTEM",
) -> dict[str, Any]:
return trace_store.create(
plan_id=plan_node.planId,
plan_version=plan_node.version,
draft_id=draft.get("draftId"),
draft_snapshot=draft,
input_summary=input_summary,
world_fingerprint=str(draft.get("worldFingerprint") or ""),
trace_id=trace_id,
created_by=created_by,
)
def persist_mps_draft(
draft: dict[str, Any],
*,
world: World,
plan_store: Any,
trace_store: MpsTraceStore,
tenant_uuid: str,
project_id: str,
world_key: str,
plan_id: str | None = None,
created_by: str = "SYSTEM",
actor: str = "SYSTEM",
) -> dict[str, Any]:
expected_scope = _scope_payload(
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
)
if trace_store.scope != expected_scope:
raise ValueError("MPS trace store scope does not match the draft scope")
validated = validate_mps_draft_binding(
world,
draft,
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
)
input_summary = _input_summary(
validated,
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
)
resolved_plan_id = plan_id or f"mps-{uuid.uuid4().hex[:12]}"
persisted_draft = copy.deepcopy(validated)
persisted_draft["planId"] = resolved_plan_id
envelope = trace_store.create(
plan_id=resolved_plan_id,
plan_version=1,
draft_id=persisted_draft.get("draftId"),
draft_snapshot=persisted_draft,
input_summary=input_summary,
world_fingerprint=str(persisted_draft.get("worldFingerprint") or ""),
created_by=actor,
)
try:
node = create_mps_plan_node(
validated,
plan_store=plan_store,
input_summary=input_summary,
plan_id=resolved_plan_id,
created_by=created_by,
)
except BaseException as exc:
try:
trace_store.discard_uncommitted(envelope["traceId"])
except BaseException as rollback_exc:
raise RuntimeError(
f"MPS Plan persistence failed and trace compensation failed: {rollback_exc}"
) from exc
raise
return {"planNode": node, "envelope": envelope, "draft": persisted_draft}
def get_mps_trace(trace_id: str, *, trace_store: MpsTraceStore) -> dict[str, Any]:
return trace_store.get(trace_id)
def replay_mps_trace(
trace_id: str,
*,
world: World,
trace_store: MpsTraceStore,
) -> dict[str, Any]:
return trace_store.replay(trace_id, world=world)
def _resolve_schedule_version(
world: World,
track: str,
schedule_id: Any = None,
) -> dict[str, Any] | None:
key = "flexScheduleVersions" if track == "flex" else "scheduleVersions"
versions = world.get(key) or []
if schedule_id is None:
return versions[-1] if versions else None
for version in versions:
try:
if int(version.get("id")) == int(schedule_id):
return version
except (TypeError, ValueError):
if str(version.get("id")) == str(schedule_id):
return version
return None
def _same_id(left: Any, right: Any) -> bool:
try:
return int(left) == int(right)
except (TypeError, ValueError):
return str(left) == str(right)
def _without_schedule_lifecycle(row: dict[str, Any]) -> dict[str, Any]:
return {
key: copy.deepcopy(value)
for key, value in row.items()
if key not in _SCHEDULE_LIFECYCLE_KEYS
}
def _schedule_projection(
world: World,
track: str,
version: dict[str, Any],
) -> dict[str, Any]:
normalized_track = str(track or "").lower()
if normalized_track not in {"fixed", "flex"}:
raise ValueError("MPS schedule track must be fixed or flex")
version_id = version.get("id")
if version_id is None:
raise ValueError("MPS schedule version is missing its id")
if normalized_track == "flex":
artifact_specs = (
("virtualLines", "flexVirtualLines", "versionId"),
("workOrders", "flexWorkOrders", "versionId"),
("conflicts", "flexConflicts", "versionId"),
)
production_orders: list[dict[str, Any]] = []
else:
production_orders = [
row
for row in (world.get("productionOrders") or [])
if _same_id(row.get("schedulingVersionId"), version_id)
]
production_order_ids = {str(row.get("id")) for row in production_orders}
artifact_specs = (
("workOrders", "workOrders", "productionOrderId"),
("conflicts", "conflicts", "versionId"),
)
projection: dict[str, Any] = {
"track": normalized_track,
"version": _without_schedule_lifecycle(version),
}
if normalized_track == "fixed":
projection["productionOrders"] = sorted(
(_without_schedule_lifecycle(row) for row in production_orders),
key=lambda row: str(row.get("id")),
)
for output_key, world_key, foreign_key in artifact_specs:
rows = world.get(world_key) or []
if normalized_track == "fixed" and world_key == "workOrders":
selected = [
row
for row in rows
if str(row.get(foreign_key)) in production_order_ids
]
else:
selected = [
row for row in rows if _same_id(row.get(foreign_key), version_id)
]
projection[output_key] = sorted(
(_without_schedule_lifecycle(row) for row in selected),
key=lambda row: str(row.get("id")),
)
return projection
def schedule_fingerprint(
world: World,
track: str,
version: dict[str, Any],
) -> str:
"""Stable hash of one exact local schedule and its version-owned artifacts."""
return _canonical_hash(_schedule_projection(world, track, version))
def _schedule_summary(
world: World,
track: str,
version: dict[str, Any],
) -> dict[str, Any]:
return {
"track": track,
"scheduleId": version.get("id"),
"scheduleVersionNo": version.get("versionNo"),
"status": version.get("status"),
"createdAt": version.get("createdAt"),
"publishedAt": version.get("publishedAt"),
"strategy": version.get("sortMode")
or version.get("strategy")
or version.get("note"),
"engineType": version.get("engineType"),
"orderCount": int(version.get("orderCount") or 0),
"woCount": int(version.get("woCount") or 0),
"conflictCount": int(version.get("conflictCount") or 0),
"scheduleFingerprint": schedule_fingerprint(world, track, version),
}
def list_mps_schedule_versions(world: World) -> list[dict[str, Any]]:
rows = [
_schedule_summary(world, track, version)
for track, key in (
("fixed", "scheduleVersions"),
("flex", "flexScheduleVersions"),
)
for version in (world.get(key) or [])
]
rows.sort(
key=lambda row: (
str(row.get("createdAt") or ""),
str(row.get("scheduleVersionNo") or ""),
str(row.get("track") or ""),
),
reverse=True,
)
return rows
def _require_schedule_version(
world: World,
*,
track: str,
schedule_id: Any,
schedule_version_no: str,
expected_fingerprint: str,
) -> tuple[dict[str, Any], dict[str, Any]]:
normalized_track = str(track or "").lower()
if normalized_track not in {"fixed", "flex"}:
raise ValueError("MPS schedule track must be fixed or flex")
version = _resolve_schedule_version(world, normalized_track, schedule_id)
if version is None:
raise ValueError("MPS schedule version does not exist on the requested track")
actual_version_no = str(version.get("versionNo") or "")
if not schedule_version_no or actual_version_no != str(schedule_version_no):
raise ValueError("MPS schedule version number does not match its id and track")
summary = _schedule_summary(world, normalized_track, version)
if summary["scheduleFingerprint"] != str(expected_fingerprint or ""):
raise ValueError("MPS schedule fingerprint is stale")
return version, summary
def link_mps_trace_local_fact(
world: World,
*,
trace_store: MpsTraceStore,
trace_id: str,
tenant_uuid: str,
project_id: str,
world_key: str,
draft_hash: str,
world_fingerprint: str,
schedule_id: Any,
schedule_version_no: str,
schedule_fingerprint: str,
track: str,
fact_type: str,
fact_id: str | None = None,
actor: str = "SYSTEM",
) -> dict[str, Any]:
"""Bind an exact local schedule and ADOPT/PUBLISH fact without world writes."""
expected_scope = _scope_payload(
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
)
if trace_store.scope != expected_scope:
raise ValueError("MPS trace store scope does not match the local link scope")
trace = trace_store.get(trace_id)
if any(trace.get(key) != value for key, value in expected_scope.items()):
raise ValueError("MPS trace scope does not match the authenticated project")
if trace.get("draftHash") != str(draft_hash or ""):
raise ValueError("MPS local link draft hash does not match its trace")
if trace.get("worldFingerprint") != str(world_fingerprint or ""):
raise ValueError("MPS local link world fingerprint does not match its trace")
validate_mps_draft_binding(
world,
trace.get("draftSnapshot") or {},
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
allow_legacy=True,
)
normalized_track = str(track or "").lower()
version, schedule = _require_schedule_version(
world,
track=normalized_track,
schedule_id=schedule_id,
schedule_version_no=schedule_version_no,
expected_fingerprint=schedule_fingerprint,
)
normalized_fact = str(fact_type or "").upper()
if normalized_fact not in {"ADOPT", "PUBLISH"}:
raise ValueError("MPS local fact must be ADOPT or PUBLISH")
schedule_status = str(version.get("status") or "").upper()
if schedule_status not in {"DRAFT", "PUBLISHED", "DISPATCHED"}:
raise ValueError("MPS local fact requires a usable local schedule version")
fact_at = None
if normalized_fact == "PUBLISH":
if schedule_status not in {"PUBLISHED", "DISPATCHED"}:
raise ValueError("MPS PUBLISH fact requires a published local version")
fact_at = str(version.get("publishedAt") or "")
if not fact_at:
raise ValueError("MPS PUBLISH fact requires the version publishedAt value")
resolved_fact_id = str(
fact_id
or f"{normalized_fact}:{normalized_track}:{schedule_version_no}"
)
linked = trace_store.link_local_fact(
trace_id,
schedule_id=schedule_id,
schedule_version_no=schedule_version_no,
track=normalized_track,
schedule_fingerprint=schedule_fingerprint,
schedule_status=schedule_status,
world_fingerprint=world_fingerprint,
fact_type=normalized_fact,
fact_id=resolved_fact_id,
fact_at=fact_at,
actor=actor,
)
envelope = linked["trace"]
at_key = "adoptedAt" if normalized_fact == "ADOPT" else "publishedAt"
return {
**linked,
"schedule": schedule,
"fact": {
"factId": resolved_fact_id,
"factType": normalized_fact,
"at": envelope.get(at_key),
"by": actor,
},
}
def _schedule_kpi(world: World, track: str, version: dict[str, Any]) -> dict[str, Any]:
order_count = int(version.get("orderCount") or 0)
on_time = int(version.get("onTimeCount") or 0)
return {
"versionNo": version.get("versionNo"),
"status": version.get("status"),
"orderCount": order_count,
"poCount": int(version.get("poCount") or 0),
"woCount": int(version.get("woCount") or 0),
"conflictCount": int(version.get("conflictCount") or 0),
"totalTardiness": round(float(version.get("totalTardiness") or 0), 1),
"avgUtilization": round(float(version.get("avgUtilization") or 0), 3),
"onTimeCount": on_time,
"onTimeRate": round(on_time / order_count, 3) if order_count else None,
"makespan": version.get("makespan"),
"bottleneck": version.get("bottleneck"),
"track": track,
}
def _planned_quantity(world: World, track: str, version: dict[str, Any]) -> float:
version_id = version.get("id")
if track == "flex":
rows = world.get("flexVirtualLines") or []
return float(
sum(
int(row.get("quantity") or 0)
for row in rows
if row.get("versionId") == version_id
)
)
rows = world.get("productionOrders") or []
return float(
sum(
int(row.get("quantity") or 0)
for row in rows
if row.get("schedulingVersionId") == version_id
)
)
def compare_mps_schedule_gap(
world: World,
draft: dict[str, Any],
*,
tenant_uuid: str,
project_id: str,
world_key: str,
schedule_id: Any = None,
track: str = "fixed",
trace: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Compare plan-layer MPS KPI with detailed scheduling KPI and summarize gaps."""
expected_scope = _scope_payload(
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
)
trusted_trace_draft = (trace or {}).get("draftSnapshot") or None
allow_legacy_effective = bool(
trace and isinstance(trusted_trace_draft, dict) and draft == trusted_trace_draft
)
effective_draft = validate_mps_draft_binding(
world,
draft,
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
allow_legacy=allow_legacy_effective,
)
tr = str(track or "fixed").lower()
if trace:
if any(trace.get(key) != value for key, value in expected_scope.items()):
raise ValueError("MPS trace scope does not match the authenticated project")
if trace.get("worldFingerprint") != effective_draft.get("worldFingerprint"):
raise ValueError("MPS trace is bound to a different world version")
trace_draft = validate_mps_draft_binding(
world,
trace.get("draftSnapshot") or {},
tenant_uuid=tenant_uuid,
project_id=project_id,
world_key=world_key,
allow_legacy=True,
)
if trace.get("draftHash") != effective_draft.get("draftHash"):
raise ValueError("MPS draft does not match the referenced trace")
if _draft_hash(trace_draft) != effective_draft.get("draftHash"):
raise ValueError("MPS trace draft snapshot is inconsistent")
linked_schedule_id = trace.get("scheduleId")
linked_track = str(trace.get("scheduleTrack") or "").lower()
if (
schedule_id is not None
and linked_schedule_id is not None
and not _same_id(linked_schedule_id, schedule_id)
):
raise ValueError("MPS scheduleId does not match the referenced trace")
if schedule_id is None:
schedule_id = linked_schedule_id
if tr == "auto":
tr = linked_track or "fixed"
elif linked_track and tr != linked_track:
raise ValueError("MPS schedule track does not match the referenced trace")
elif tr == "auto":
tr = "fixed"
if tr not in ("fixed", "flex"):
raise ValueError("MPS schedule track must be fixed, flex, or auto")
version = _resolve_schedule_version(world, tr, schedule_id)
if trace and trace.get("scheduleId") is not None:
if version is None:
raise ValueError("MPS linked schedule version no longer exists")
if str(version.get("versionNo") or "") != str(
trace.get("scheduleVersionNo") or ""
):
raise ValueError("MPS linked schedule version number has drifted")
expected_schedule_fingerprint = str(
trace.get("scheduleFingerprint") or ""
)
if not expected_schedule_fingerprint:
raise ValueError("MPS linked schedule is missing its fingerprint")
if schedule_fingerprint(world, tr, version) != expected_schedule_fingerprint:
raise ValueError("MPS linked schedule fingerprint has drifted")
summary = effective_draft.get("summary") or {}
capacity = effective_draft.get("capacity") or {}
plan_kpi = {
"weightedDemand": round(float(summary.get("weightedDemand") or 0), 1),
"lineCount": int(summary.get("lineCount") or 0),
"overallLoad": float(summary.get("overallLoad") or 0),
"feasibleRate": float(capacity.get("feasibleRate") or 0),
"overCount": int(summary.get("overCount") or 0),
"warnCount": int(summary.get("warnCount") or 0),
"peakLoadRatio": float(capacity.get("peakLoadRatio") or 0),
"horizonDays": int(effective_draft.get("horizonDays") or 0),
}
if version is None:
return {
"track": tr,
"plan": plan_kpi,
"schedule": None,
"gaps": None,
"rows": [],
"trace": {
"planId": (trace or {}).get("planId"),
"scheduleId": schedule_id,
"scheduleVersionNo": (trace or {}).get("scheduleVersionNo"),
"scheduleTrack": (trace or {}).get("scheduleTrack"),
"scheduleFingerprint": (trace or {}).get("scheduleFingerprint"),
"adoptId": (trace or {}).get("adoptId"),
"publishId": (trace or {}).get("publishId"),
"feedbackCount": len((trace or {}).get("executionFeedback") or []),
},
"message": "No schedule version available for detailed KPI comparison.",
}
schedule_kpi = _schedule_kpi(world, tr, version)
planned_qty = _planned_quantity(world, tr, version)
gaps = {
"demandQty": plan_kpi["weightedDemand"],
"plannedQty": round(planned_qty, 1),
"demandGapQty": round(plan_kpi["weightedDemand"] - planned_qty, 1),
"loadRatioGap": round(
plan_kpi["overallLoad"] - schedule_kpi["avgUtilization"], 3
),
"feasibleRateGap": round(
plan_kpi["feasibleRate"] - (schedule_kpi["onTimeRate"] or 0), 3
),
"riskCountGap": max(
0,
plan_kpi["overCount"]
+ plan_kpi["warnCount"]
- schedule_kpi["conflictCount"],
),
}
rows = [
{
"key": "demandQty",
"name": "weighted demand qty",
"plan": plan_kpi["weightedDemand"],
"schedule": round(planned_qty, 1),
"delta": gaps["demandGapQty"],
"lowerBetter": False,
},
{
"key": "loadRatio",
"name": "load ratio",
"plan": plan_kpi["overallLoad"],
"schedule": schedule_kpi["avgUtilization"],
"delta": gaps["loadRatioGap"],
"lowerBetter": True,
},
{
"key": "feasibleRate",
"name": "feasible rate",
"plan": plan_kpi["feasibleRate"],
"schedule": schedule_kpi["onTimeRate"],
"delta": gaps["feasibleRateGap"],
"lowerBetter": False,
},
{
"key": "riskCount",
"name": "risk count",
"plan": plan_kpi["overCount"] + plan_kpi["warnCount"],
"schedule": schedule_kpi["conflictCount"],
"delta": gaps["riskCountGap"],
"lowerBetter": True,
},
]
message = (
f"Plan demand {plan_kpi['weightedDemand']} vs scheduled qty {round(planned_qty, 1)}; "
f"rough-cut feasible rate {plan_kpi['feasibleRate']} vs detailed on-time rate "
f"{schedule_kpi['onTimeRate']}."
)
return {
"track": tr,
"plan": plan_kpi,
"schedule": schedule_kpi,
"gaps": gaps,
"rows": rows,
"trace": {
"planId": (trace or {}).get("planId"),
"scheduleId": schedule_id,
"scheduleVersionNo": (trace or {}).get("scheduleVersionNo"),
"scheduleTrack": (trace or {}).get("scheduleTrack"),
"scheduleFingerprint": (trace or {}).get("scheduleFingerprint"),
"adoptId": (trace or {}).get("adoptId"),
"publishId": (trace or {}).get("publishId"),
"feedbackCount": len((trace or {}).get("executionFeedback") or []),
},
"message": message,
}