完善系统仿真优化计划交互
This commit is contained in:
1 parent
42ffdfff7d
commit
50c9f59d78
4 files changed
+812
-24
No files matched your search
@@ -14,6 +14,7 @@ import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import math
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
@@ -107,9 +108,15 @@ def _json_bytes(value: object) -> bytes:
|
||||
def _valid_spec(
|
||||
*,
|
||||
seed: int = 12345,
|
||||
population_size: int = 4,
|
||||
mutation_factor: float = 0.8,
|
||||
crossover_probability: float = 0.7,
|
||||
max_simulation_runs: int = 6,
|
||||
lower: float = 0.0,
|
||||
upper: float = 4.0,
|
||||
relative_tolerance: float = 1e-12,
|
||||
absolute_tolerance: float = 1e-12,
|
||||
constraint_scale: float = 1.0,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"optimizationSchemaVersion": 1,
|
||||
@@ -138,23 +145,23 @@ def _valid_spec(
|
||||
"lower": None,
|
||||
"upper": 4.0,
|
||||
"tolerance": 0.0,
|
||||
"scale": 1.0,
|
||||
"scale": constraint_scale,
|
||||
}
|
||||
],
|
||||
"algorithm": {
|
||||
"name": "differentialEvolution",
|
||||
"seed": seed,
|
||||
"populationSize": 4,
|
||||
"mutationFactor": 0.8,
|
||||
"crossoverProbability": 0.7,
|
||||
"populationSize": population_size,
|
||||
"mutationFactor": mutation_factor,
|
||||
"crossoverProbability": crossover_probability,
|
||||
},
|
||||
"budget": {
|
||||
"maxSimulationRuns": max_simulation_runs,
|
||||
"maxWallSeconds": 60.0,
|
||||
},
|
||||
"validation": {
|
||||
"relativeTolerance": 1e-12,
|
||||
"absoluteTolerance": 1e-12,
|
||||
"relativeTolerance": relative_tolerance,
|
||||
"absoluteTolerance": absolute_tolerance,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -207,9 +214,15 @@ def _make_plan(
|
||||
*,
|
||||
output_name: str = "optimization-output",
|
||||
seed: int = 12345,
|
||||
population_size: int = 4,
|
||||
mutation_factor: float = 0.8,
|
||||
crossover_probability: float = 0.7,
|
||||
max_simulation_runs: int = 6,
|
||||
lower: float = 0.0,
|
||||
upper: float = 4.0,
|
||||
relative_tolerance: float = 1e-12,
|
||||
absolute_tolerance: float = 1e-12,
|
||||
constraint_scale: float = 1.0,
|
||||
inspection: dict[str, object] | None = None,
|
||||
) -> optimization.RuntimePlan:
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
@@ -217,9 +230,15 @@ def _make_plan(
|
||||
source_path.write_bytes(_json_bytes(PROJECT))
|
||||
spec_payload = _valid_spec(
|
||||
seed=seed,
|
||||
population_size=population_size,
|
||||
mutation_factor=mutation_factor,
|
||||
crossover_probability=crossover_probability,
|
||||
max_simulation_runs=max_simulation_runs,
|
||||
lower=lower,
|
||||
upper=upper,
|
||||
relative_tolerance=relative_tolerance,
|
||||
absolute_tolerance=absolute_tolerance,
|
||||
constraint_scale=constraint_scale,
|
||||
)
|
||||
spec_path = directory / "optimization-spec.json"
|
||||
spec_path.write_bytes(_json_bytes(spec_payload))
|
||||
@@ -268,6 +287,18 @@ def _xml_parameter_values(xml: bytes) -> dict[tuple[str, str], str]:
|
||||
return values
|
||||
|
||||
|
||||
def _recursive_mapping_keys(value: object) -> list[str]:
|
||||
keys: list[str] = []
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
keys.append(str(key))
|
||||
keys.extend(_recursive_mapping_keys(child))
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
keys.extend(_recursive_mapping_keys(child))
|
||||
return keys
|
||||
|
||||
|
||||
class OptimizationSpecTests(unittest.TestCase):
|
||||
def test_spec_rejects_unknown_fields_at_every_nested_contract(self) -> None:
|
||||
parsed = optimization.parse_optimization_spec(_valid_spec())
|
||||
@@ -807,6 +838,458 @@ class CandidateIsolationTests(unittest.TestCase):
|
||||
self.assertEqual(plan.baseline_xml, BASELINE_XML)
|
||||
|
||||
|
||||
class PlanPresentationTests(unittest.TestCase):
|
||||
_FORBIDDEN_KEY_FRAGMENTS = (
|
||||
"sha256",
|
||||
"hash",
|
||||
"token",
|
||||
"algorithm",
|
||||
"seed",
|
||||
"population",
|
||||
"searchrunlimit",
|
||||
"fullgenerations",
|
||||
"requiredassertions",
|
||||
"parametercontinuity",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _recommended_plan(
|
||||
directory: Path,
|
||||
*,
|
||||
max_simulation_runs: int = 25,
|
||||
output_name: str = "optimization-output",
|
||||
inspection: dict[str, object] | None = None,
|
||||
) -> optimization.RuntimePlan:
|
||||
return _make_plan(
|
||||
directory,
|
||||
output_name=output_name,
|
||||
seed=optimization.RECOMMENDED_ALGORITHM_SEED,
|
||||
population_size=optimization.RECOMMENDED_POPULATION_SIZE,
|
||||
mutation_factor=optimization.RECOMMENDED_MUTATION_FACTOR,
|
||||
crossover_probability=(
|
||||
optimization.RECOMMENDED_CROSSOVER_PROBABILITY
|
||||
),
|
||||
max_simulation_runs=max_simulation_runs,
|
||||
relative_tolerance=(
|
||||
optimization.RECOMMENDED_VALIDATION_RELATIVE_TOLERANCE
|
||||
),
|
||||
absolute_tolerance=(
|
||||
optimization.RECOMMENDED_VALIDATION_ABSOLUTE_TOLERANCE
|
||||
),
|
||||
constraint_scale=4.0,
|
||||
inspection=inspection,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _command_args(
|
||||
plan: optimization.RuntimePlan,
|
||||
*,
|
||||
present: bool = False,
|
||||
plan_file: Path | None = None,
|
||||
) -> argparse.Namespace:
|
||||
return argparse.Namespace(
|
||||
input=str(plan.source.path),
|
||||
spec=str(plan.spec_source.path),
|
||||
output_dir=str(plan.output_directory),
|
||||
base_url=plan.base_url,
|
||||
timeout=plan.timeout,
|
||||
present=present,
|
||||
plan_file=str(plan_file) if plan_file is not None else None,
|
||||
)
|
||||
|
||||
def assert_safe_presentation(self, payload: object) -> None:
|
||||
normalized_keys = {
|
||||
"".join(character for character in key.casefold() if character.isalnum())
|
||||
for key in _recursive_mapping_keys(payload)
|
||||
}
|
||||
leaked = {
|
||||
key
|
||||
for key in normalized_keys
|
||||
if any(
|
||||
fragment in key for fragment in self._FORBIDDEN_KEY_FRAGMENTS
|
||||
)
|
||||
}
|
||||
self.assertEqual(leaked, set())
|
||||
|
||||
def test_presentation_is_a_closed_human_facing_view(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
inspection = _inspection()
|
||||
result_variables = inspection["system"]["resultVariables"]
|
||||
assert isinstance(result_variables, list)
|
||||
for variable in result_variables:
|
||||
assert isinstance(variable, dict)
|
||||
variable["backendOnly"] = "BACKEND-METADATA-SENTINEL"
|
||||
plan = self._recommended_plan(
|
||||
Path(directory_text),
|
||||
max_simulation_runs=17,
|
||||
inspection=inspection,
|
||||
)
|
||||
|
||||
presentation = plan.presentation_dict()
|
||||
|
||||
self.assertEqual(
|
||||
set(presentation),
|
||||
{
|
||||
"objective",
|
||||
"designVariables",
|
||||
"constraints",
|
||||
"runLimits",
|
||||
"assumptions",
|
||||
"outputDirectory",
|
||||
"sourceWillBeOverwritten",
|
||||
},
|
||||
)
|
||||
objective = presentation["objective"]
|
||||
self.assertIsInstance(objective, dict)
|
||||
self.assertEqual(objective["resultKey"], "sensor.output")
|
||||
objective_text = json.dumps(objective, ensure_ascii=False)
|
||||
self.assertIn("final", objective_text)
|
||||
self.assertIn("minimize", objective_text)
|
||||
self.assertIn("m", objective_text)
|
||||
|
||||
design_variables = presentation["designVariables"]
|
||||
self.assertIsInstance(design_variables, list)
|
||||
self.assertEqual(len(design_variables), 1)
|
||||
design_variable = design_variables[0]
|
||||
self.assertEqual(
|
||||
set(design_variable),
|
||||
{
|
||||
"id",
|
||||
"componentId",
|
||||
"parameter",
|
||||
"label",
|
||||
"quantity",
|
||||
"current",
|
||||
"lower",
|
||||
"upper",
|
||||
"unit",
|
||||
},
|
||||
)
|
||||
self.assertEqual(design_variable["id"], "gain")
|
||||
self.assertEqual(design_variable["componentId"], "component-a")
|
||||
self.assertEqual(design_variable["parameter"], "gain")
|
||||
self.assertEqual(design_variable["current"], 2.0)
|
||||
self.assertEqual(design_variable["lower"], 0.0)
|
||||
self.assertEqual(design_variable["upper"], 4.0)
|
||||
self.assertEqual(design_variable["unit"], "m")
|
||||
|
||||
constraints = presentation["constraints"]
|
||||
self.assertIsInstance(constraints, list)
|
||||
self.assertEqual(len(constraints), 1)
|
||||
constraint_text = json.dumps(constraints[0], ensure_ascii=False)
|
||||
self.assertIn("sensor.limit", constraint_text)
|
||||
self.assertIn("maximum", constraint_text)
|
||||
self.assertIn("4.0", constraint_text)
|
||||
|
||||
self.assertEqual(
|
||||
presentation["runLimits"],
|
||||
{
|
||||
"maxSimulationBudgetSlots": 17,
|
||||
"searchLaunchTimeLimitSeconds": 60.0,
|
||||
"inFlightSimulationMayFinishAfterLimit": True,
|
||||
"freshVerificationSlotsReserved": 1,
|
||||
"verificationRunsOnlyIfFeasibleCandidateFound": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
presentation["assumptions"],
|
||||
{
|
||||
"continuousLinearSiDesignVariableIds": ["gain"],
|
||||
"portsTopologyModesUnchanged": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
presentation["outputDirectory"], str(plan.output_directory)
|
||||
)
|
||||
self.assertFalse(presentation["sourceWillBeOverwritten"])
|
||||
|
||||
self.assert_safe_presentation(presentation)
|
||||
serialized = json.dumps(presentation, ensure_ascii=False)
|
||||
for forbidden_value in (
|
||||
plan.source.sha256,
|
||||
plan.spec_source.sha256,
|
||||
plan.confirmation_token,
|
||||
hashlib.sha256(plan.baseline_xml).hexdigest(),
|
||||
"differentialEvolution",
|
||||
"BACKEND-METADATA-SENTINEL",
|
||||
plan.base_url,
|
||||
):
|
||||
self.assertNotIn(forbidden_value, serialized)
|
||||
|
||||
def test_presentation_only_adds_attention_when_budget_is_insufficient(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
root = Path(directory_text)
|
||||
sufficient = self._recommended_plan(
|
||||
root / "sufficient", max_simulation_runs=17
|
||||
)
|
||||
insufficient = self._recommended_plan(
|
||||
root / "insufficient", max_simulation_runs=9
|
||||
)
|
||||
|
||||
sufficient_presentation = sufficient.presentation_dict()
|
||||
insufficient_presentation = insufficient.presentation_dict()
|
||||
|
||||
self.assertEqual(sufficient.public_dict()["warnings"], [])
|
||||
self.assertNotIn("warnings", sufficient_presentation)
|
||||
self.assertNotIn("attention", sufficient_presentation)
|
||||
|
||||
self.assertEqual(
|
||||
[item["code"] for item in insufficient.public_dict()["warnings"]],
|
||||
["OPTIMIZATION_BUDGET_INITIAL_POPULATION_ONLY"],
|
||||
)
|
||||
self.assertNotIn("warnings", insufficient_presentation)
|
||||
self.assertIn("attention", insufficient_presentation)
|
||||
self.assertTrue(insufficient_presentation["attention"])
|
||||
attention_text = json.dumps(
|
||||
insufficient_presentation["attention"], ensure_ascii=False
|
||||
)
|
||||
self.assertNotIn(
|
||||
"OPTIMIZATION_BUDGET_INITIAL_POPULATION_ONLY", attention_text
|
||||
)
|
||||
self.assertNotIn("generation", attention_text.casefold())
|
||||
self.assert_safe_presentation(insufficient_presentation)
|
||||
|
||||
def test_presentation_discloses_only_non_default_confirmed_settings(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
plan = _make_plan(Path(directory_text))
|
||||
|
||||
presentation = plan.presentation_dict()
|
||||
|
||||
self.assertEqual(
|
||||
presentation["nonDefaultSettings"],
|
||||
{
|
||||
"search": {
|
||||
"randomSeed": 12345,
|
||||
"populationSize": 4,
|
||||
},
|
||||
"verification": {
|
||||
"relativeTolerance": 1e-12,
|
||||
"absoluteTolerance": 1e-12,
|
||||
},
|
||||
"constraintRankingScales": [
|
||||
{"id": "limit", "scale": 1.0}
|
||||
],
|
||||
},
|
||||
)
|
||||
serialized = json.dumps(presentation, ensure_ascii=False)
|
||||
for forbidden_value in (
|
||||
plan.source.sha256,
|
||||
plan.spec_source.sha256,
|
||||
plan.confirmation_token,
|
||||
plan.base_url,
|
||||
"differentialEvolution",
|
||||
):
|
||||
self.assertNotIn(forbidden_value, serialized)
|
||||
|
||||
def test_command_plan_preserves_legacy_audit_stdout_without_new_flags(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
plan = _make_plan(Path(directory_text))
|
||||
args = self._command_args(plan)
|
||||
with mock.patch.object(
|
||||
optimization, "build_runtime_plan", return_value=plan
|
||||
), mock.patch.object(
|
||||
optimization.simulation, "emit_json"
|
||||
) as emit_json:
|
||||
result = optimization.command_plan(args)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
emit_json.assert_called_once_with(plan.public_dict())
|
||||
self.assertFalse(plan.output_directory.exists())
|
||||
|
||||
def test_command_plan_present_saves_audit_plan_and_emits_safe_wrapper(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
root = Path(directory_text)
|
||||
plan = self._recommended_plan(root)
|
||||
plan_file = root / "saved-optimization-plan.json"
|
||||
args = self._command_args(plan, present=True, plan_file=plan_file)
|
||||
with mock.patch.object(
|
||||
optimization, "build_runtime_plan", return_value=plan
|
||||
), mock.patch.object(
|
||||
optimization.simulation, "emit_json"
|
||||
) as emit_json, mock.patch.object(
|
||||
optimization,
|
||||
"_write_new_private_file",
|
||||
wraps=optimization._write_new_private_file,
|
||||
) as write_new_file:
|
||||
result = optimization.command_plan(args)
|
||||
|
||||
self.assertEqual(result, 0)
|
||||
write_new_file.assert_called_once()
|
||||
written_path, written_bytes = write_new_file.call_args.args
|
||||
self.assertEqual(written_path, plan_file)
|
||||
self.assertEqual(json.loads(written_bytes), plan.public_dict())
|
||||
self.assertEqual(
|
||||
json.loads(plan_file.read_text(encoding="utf-8")),
|
||||
plan.public_dict(),
|
||||
)
|
||||
self.assertEqual(stat.S_IMODE(plan_file.stat().st_mode), 0o600)
|
||||
self.assertEqual(
|
||||
list(root.glob(f".{plan_file.name}.*.tmp")),
|
||||
[],
|
||||
)
|
||||
expected_stdout = {
|
||||
"ok": True,
|
||||
"command": "optimization-plan",
|
||||
"view": "presentation",
|
||||
"confirmationRequired": True,
|
||||
"presentation": plan.presentation_dict(),
|
||||
}
|
||||
emit_json.assert_called_once_with(expected_stdout)
|
||||
self.assert_safe_presentation(expected_stdout["presentation"])
|
||||
|
||||
def test_command_plan_requires_present_and_plan_file_together(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
root = Path(directory_text)
|
||||
plan = _make_plan(root)
|
||||
cases = (
|
||||
self._command_args(plan, present=True),
|
||||
self._command_args(
|
||||
plan,
|
||||
plan_file=root / "unpaired-plan.json",
|
||||
),
|
||||
)
|
||||
for args in cases:
|
||||
with self.subTest(
|
||||
present=args.present, plan_file=args.plan_file
|
||||
), mock.patch.object(
|
||||
optimization, "build_runtime_plan", return_value=plan
|
||||
) as build_runtime_plan, mock.patch.object(
|
||||
optimization.simulation, "emit_json"
|
||||
) as emit_json:
|
||||
with self.assertRaises(optimization.simulation.InputError):
|
||||
optimization.command_plan(args)
|
||||
build_runtime_plan.assert_not_called()
|
||||
emit_json.assert_not_called()
|
||||
self.assertFalse((root / "unpaired-plan.json").exists())
|
||||
|
||||
def test_command_plan_rejects_plan_file_inside_or_around_output_directory(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
root = Path(directory_text)
|
||||
plan = _make_plan(root, output_name="reserved/output")
|
||||
cases = {
|
||||
"same": plan.output_directory,
|
||||
"descendant": plan.output_directory / "plan.json",
|
||||
"ancestor": plan.output_directory.parent,
|
||||
}
|
||||
for label, plan_file in cases.items():
|
||||
args = self._command_args(
|
||||
plan, present=True, plan_file=plan_file
|
||||
)
|
||||
with self.subTest(label=label), mock.patch.object(
|
||||
optimization, "build_runtime_plan", return_value=plan
|
||||
), mock.patch.object(
|
||||
optimization.simulation, "emit_json"
|
||||
) as emit_json:
|
||||
with self.assertRaises(optimization.simulation.InputError):
|
||||
optimization.command_plan(args)
|
||||
emit_json.assert_not_called()
|
||||
|
||||
def test_command_plan_never_overwrites_an_existing_plan_file(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
root = Path(directory_text)
|
||||
plan = _make_plan(root)
|
||||
plan_file = root / "existing-plan.json"
|
||||
original = b"keep this exact file\n"
|
||||
plan_file.write_bytes(original)
|
||||
args = self._command_args(plan, present=True, plan_file=plan_file)
|
||||
|
||||
with mock.patch.object(
|
||||
optimization, "build_runtime_plan", return_value=plan
|
||||
), mock.patch.object(
|
||||
optimization.simulation, "emit_json"
|
||||
) as emit_json:
|
||||
with self.assertRaises(optimization.simulation.InputError):
|
||||
optimization.command_plan(args)
|
||||
|
||||
self.assertEqual(plan_file.read_bytes(), original)
|
||||
emit_json.assert_not_called()
|
||||
|
||||
def test_command_plan_rejects_a_broken_plan_file_symlink(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
root = Path(directory_text)
|
||||
plan = _make_plan(root)
|
||||
target = root / "missing-target.json"
|
||||
plan_file = root / "plan-link.json"
|
||||
plan_file.symlink_to(target)
|
||||
args = self._command_args(plan, present=True, plan_file=plan_file)
|
||||
|
||||
with mock.patch.object(
|
||||
optimization, "build_runtime_plan", return_value=plan
|
||||
) as build_runtime_plan, mock.patch.object(
|
||||
optimization.simulation, "emit_json"
|
||||
) as emit_json:
|
||||
with self.assertRaises(optimization.simulation.InputError):
|
||||
optimization.command_plan(args)
|
||||
|
||||
build_runtime_plan.assert_not_called()
|
||||
emit_json.assert_not_called()
|
||||
self.assertTrue(plan_file.is_symlink())
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_command_plan_reports_a_parent_symlink_loop_as_input_error(
|
||||
self,
|
||||
) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
root = Path(directory_text)
|
||||
plan = _make_plan(root)
|
||||
loop = root / "loop"
|
||||
loop.symlink_to(loop.name)
|
||||
plan_file = loop / "plan.json"
|
||||
args = self._command_args(plan, present=True, plan_file=plan_file)
|
||||
|
||||
with mock.patch.object(
|
||||
optimization, "build_runtime_plan", return_value=plan
|
||||
) as build_runtime_plan, mock.patch.object(
|
||||
optimization.simulation, "emit_json"
|
||||
) as emit_json:
|
||||
with self.assertRaises(
|
||||
optimization.simulation.InputError
|
||||
) as raised:
|
||||
optimization.command_plan(args)
|
||||
|
||||
self.assertEqual(
|
||||
raised.exception.code,
|
||||
"OPTIMIZATION_PLAN_FILE_PATH_INVALID",
|
||||
)
|
||||
build_runtime_plan.assert_not_called()
|
||||
emit_json.assert_not_called()
|
||||
|
||||
def test_command_plan_does_not_emit_when_plan_file_write_fails(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
root = Path(directory_text)
|
||||
plan = _make_plan(root)
|
||||
plan_file = root / "unwritten-plan.json"
|
||||
args = self._command_args(plan, present=True, plan_file=plan_file)
|
||||
write_error = optimization.simulation.ArtifactError(
|
||||
"OUTPUT_WRITE_FAILED",
|
||||
"simulated exclusive write failure",
|
||||
{"path": str(plan_file)},
|
||||
)
|
||||
|
||||
with mock.patch.object(
|
||||
optimization, "build_runtime_plan", return_value=plan
|
||||
), mock.patch.object(
|
||||
optimization,
|
||||
"_write_new_private_file",
|
||||
side_effect=write_error,
|
||||
), mock.patch.object(
|
||||
optimization.simulation, "emit_json"
|
||||
) as emit_json:
|
||||
with self.assertRaises(optimization.simulation.ArtifactError):
|
||||
optimization.command_plan(args)
|
||||
|
||||
emit_json.assert_not_called()
|
||||
self.assertFalse(plan_file.exists())
|
||||
|
||||
|
||||
class RankingAndConfirmationTests(unittest.TestCase):
|
||||
def test_plan_warns_when_budget_cannot_cover_a_complete_generation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory_text:
|
||||
|
||||
Reference in new issue
Block a user