115 lines
6.1 KiB
Python
115 lines
6.1 KiB
Python
|
|
"""Deterministic balanced work sizing after semantic family review."""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import math
|
||
|
|
from collections import Counter
|
||
|
|
|
||
|
|
from suite_contract import Problem, digest, validate_result
|
||
|
|
from suite_policy import MAX_GROUP, POLICY_REVISION, projection
|
||
|
|
|
||
|
|
|
||
|
|
def balanced_sizes(count):
|
||
|
|
"""Compute the minimum number of bounded parts without remainder singletons."""
|
||
|
|
if type(count) is not int or count < 1:
|
||
|
|
raise ValueError("positive case count required")
|
||
|
|
parts = math.ceil(count / MAX_GROUP)
|
||
|
|
base, remainder = divmod(count, parts)
|
||
|
|
return [base + 1] * remainder + [base] * (parts - remainder)
|
||
|
|
|
||
|
|
|
||
|
|
def ordered_parts(group, by_alias):
|
||
|
|
"""Pack variation hints into fixed balanced sizes; equivalent text stays stable."""
|
||
|
|
def content(alias):
|
||
|
|
return digest({k: v for k, v in by_alias[alias].items() if k != "alias"})
|
||
|
|
# Canonicalize every hint, not its model-generated order. Identical records use
|
||
|
|
# the same hint owner even if the model placed their aliases in different hints.
|
||
|
|
hints = [sorted(part, key=lambda a: (content(a), a)) for part in group["variation_sets"]]
|
||
|
|
hints.sort(key=lambda part: tuple(sorted((content(a), a) for a in part)))
|
||
|
|
owner = {}
|
||
|
|
for index, part in enumerate(hints):
|
||
|
|
for alias in part:
|
||
|
|
owner.setdefault(content(alias), index)
|
||
|
|
buckets = {}
|
||
|
|
for alias in group["members"]:
|
||
|
|
buckets.setdefault(owner[content(alias)], []).append(alias)
|
||
|
|
blocks = [sorted(part, key=lambda a: (content(a), a)) for part in buckets.values()]
|
||
|
|
blocks.sort(key=lambda part: (-len(part), tuple((content(a), a) for a in part)))
|
||
|
|
sizes = balanced_sizes(len(group["members"]))
|
||
|
|
parts = [[] for _ in sizes]
|
||
|
|
for block in blocks:
|
||
|
|
left = list(block)
|
||
|
|
while left:
|
||
|
|
room = [size - len(part) for size, part in zip(sizes, parts)]
|
||
|
|
fits = [i for i, free in enumerate(room) if free >= len(left)]
|
||
|
|
index = min(fits, key=lambda i: (room[i], i)) if fits else max(range(len(room)), key=lambda i: (room[i], -i))
|
||
|
|
count = min(room[index], len(left))
|
||
|
|
if count <= 0:
|
||
|
|
raise Problem("invalid_capacity_partition", 502)
|
||
|
|
parts[index].extend(left[:count])
|
||
|
|
left = left[count:]
|
||
|
|
parts = [sorted(part) for part in parts]
|
||
|
|
parts.sort(key=lambda part: (-len(part), tuple(part)))
|
||
|
|
if [len(part) for part in parts] != sizes or Counter(a for p in parts for a in p) != Counter(group["members"]):
|
||
|
|
raise Problem("invalid_capacity_partition", 502)
|
||
|
|
return parts
|
||
|
|
|
||
|
|
|
||
|
|
def cap_families(natural, request):
|
||
|
|
"""Create only final tasks, retaining conceptual-family sizing explanations."""
|
||
|
|
by_alias = {case["alias"]: case for case in request["cases"]}
|
||
|
|
final, divisions = [], []
|
||
|
|
for group in sorted(natural["groups"], key=lambda g: (" ".join(g["name"].split()).casefold(), sorted(g["members"]))):
|
||
|
|
base_name = " ".join(group["name"].split())
|
||
|
|
if len(group["members"]) <= MAX_GROUP:
|
||
|
|
final.append({"name": base_name, "description": group["description"], "members": sorted(group["members"])})
|
||
|
|
continue
|
||
|
|
parts = ordered_parts(group, by_alias)
|
||
|
|
names = []
|
||
|
|
for index, members in enumerate(parts, 1):
|
||
|
|
name = f"{base_name} ({index}/{len(parts)})"
|
||
|
|
names.append(name)
|
||
|
|
final.append({"name": name,
|
||
|
|
"description": f"Work-size part {index}/{len(parts)} of one family. {group['common_work']}",
|
||
|
|
"members": members})
|
||
|
|
divisions.append({"family_name": base_name, "natural_case_count": len(group["members"]),
|
||
|
|
"part_sizes": [len(part) for part in parts], "part_names": names,
|
||
|
|
"common_work": group["common_work"], "rationale": group["rationale"],
|
||
|
|
"uncertainty": group["uncertainty"]})
|
||
|
|
result = validate_result({"groups": final}, request)
|
||
|
|
# Recheck final names and memberships against each declared capacity division.
|
||
|
|
by_name = {group["name"]: group for group in final}
|
||
|
|
for division in divisions:
|
||
|
|
if [len(by_name[name]["members"]) for name in division["part_names"]] != balanced_sizes(division["natural_case_count"]):
|
||
|
|
raise Problem("invalid_capacity_partition", 502)
|
||
|
|
return result, divisions
|
||
|
|
|
||
|
|
|
||
|
|
def disagreements(a, b):
|
||
|
|
"""Compare co-membership sets, storing compressed partitions rather than O(n^2) pairs."""
|
||
|
|
def memberships(value):
|
||
|
|
return {alias: frozenset(g["members"]) for g in value["groups"] for alias in g["members"]}
|
||
|
|
first, second = memberships(a), memberships(b)
|
||
|
|
changed = sorted(alias for alias in first if first[alias] != second[alias])
|
||
|
|
pair_count = sum(len(first[alias] ^ second[alias]) for alias in first) // 2
|
||
|
|
return {"aliases": changed, "pair_count": pair_count,
|
||
|
|
"proposal_a": sorted(sorted(g["members"]) for g in a["groups"]),
|
||
|
|
"proposal_b": sorted(sorted(g["members"]) for g in b["groups"])}
|
||
|
|
|
||
|
|
|
||
|
|
def review_summary(a, b, reconciled, reviewed, audited, final, divisions):
|
||
|
|
"""Build an authorized result-only explanation, never routine operational metadata."""
|
||
|
|
natural = audited or reconciled
|
||
|
|
return {"policy_revision": POLICY_REVISION,
|
||
|
|
"proposal_disagreements": disagreements(a, b),
|
||
|
|
"reconciled_families": reconciled["groups"],
|
||
|
|
"large_family_review": reviewed.get("decisions", []) if reviewed else [],
|
||
|
|
"decision_audit": audited.get("decisions", []) if audited else [],
|
||
|
|
"natural_families": natural["groups"], "capacity_divisions": divisions,
|
||
|
|
"unresolved_uncertainties": [{"family_name": g["name"], "members": sorted(g["members"]),
|
||
|
|
"uncertainty": g["uncertainty"]}
|
||
|
|
for g in natural["groups"] if g["uncertainty"].strip()],
|
||
|
|
"counts": {"natural_families": len(natural["groups"]), "final_tasks": len(final["groups"]),
|
||
|
|
"natural_singletons": sum(len(g["members"]) == 1 for g in natural["groups"]),
|
||
|
|
"final_singletons": sum(len(g["members"]) == 1 for g in final["groups"])},
|
||
|
|
"sizing_is_not_semantic_evidence": True}
|