From 0ed6547ea2664b28abd343c1853e24c3b297eb7e Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Mon, 12 Oct 2026 19:15:51 +0100 Subject: [PATCH 1/3] feat(optimization): persist accepted-point envelope evidence Bind merged accepted-point operating-envelope evidence to the existing continuous-task production guard and append-only ledger. Validate cycle, optimizer, ordered actions/setpoints, complete unique slices, safe model recovery, and aggregate provenance fail-closed before accepting advice. Expose ordered action identities from the Java evidence exporter, extend the synthetic field acceptance workflow and Python guard coverage, and document the persistent handoff. Refs #4188, #3154, #4187, #2939. --- .../SKILL.md | 5 + devtools/neqsim_continuous/living.py | 4 +- devtools/neqsim_continuous/production.py | 131 +++++++++++ devtools/test_neqsim_production.py | 218 ++++++++++++++++-- docs/development/CONTINUOUS_TASK_SOLVING.md | 17 +- .../EXTERNAL_OPTIMIZER_INTEGRATION.md | 8 + .../ProcessModelOperatingEnvelopeStudy.java | 5 + .../neqsim/mcp/engineering-coverage.json | 2 +- ...dToFacilityOptimizationAcceptanceTest.java | 3 + ...rocessModelOperatingEnvelopeStudyTest.java | 3 + 10 files changed, 379 insertions(+), 17 deletions(-) diff --git a/.github/skills/neqsim-continuous-task-improvement/SKILL.md b/.github/skills/neqsim-continuous-task-improvement/SKILL.md index 4d92391e17c..e2b995ed4dd 100644 --- a/.github/skills/neqsim-continuous-task-improvement/SKILL.md +++ b/.github/skills/neqsim-continuous-task-improvement/SKILL.md @@ -198,6 +198,11 @@ writes to a control system and every proposal carries `requires_approval`. candidates. It keeps only proposals that also satisfy every hard constraint with margin; every rejection is listed in `guard.json`. Also record a positive integer `evaluation_count` and finite non-negative `runtime_seconds`; the ledger retains the evidence block for persistent incumbent and performance provenance. + Field studies can set `production.require_envelope_evidence: true` and attach the Java + `getAcceptedPointEnvelopeEvidence(...)` map as `accepted_point_envelope_evidence`. The guard requires exact + cycle/optimizer identity, action-id/setpoint binding, complete uniquely addressed slices, safe recovery of every + sampled point, and reconciled point/evaluation/runtime totals before the existing ledger persists it. A supplied + block is always validated even when the setting is false; never copy envelope evidence into a later cycle. - **Outcome**: ledger items set to `implemented` with `objective_kpi` and `baseline_value` are compared with the realised gain each cycle (`outcome_confirmed` / `outcome_miss`); the engineer decides on `verified`. - **Boundary conditions**: read the measured process boundary (here gas export and injection pressure and diff --git a/devtools/neqsim_continuous/living.py b/devtools/neqsim_continuous/living.py index 54efbad644f..4742ecf2754 100644 --- a/devtools/neqsim_continuous/living.py +++ b/devtools/neqsim_continuous/living.py @@ -72,9 +72,11 @@ gates: [] # - {name: sep_T_residual, kpi: max_sep_T_residual_K, abs_max: 3.0} # no advice if the model is off # - {name: mass_balance, kpi: mass_balance_pct, abs_max: 0.05} -production: {min_gain: 0.0, outcome_tolerance: 0.5} +production: {min_gain: 0.0, outcome_tolerance: 0.5, require_envelope_evidence: false} # Each proposal must include optimizer_evidence for this cycle: converged and finite candidate, # complete constraint evidence, complete state restoration, accepted-point replay, and all actions applied. +# Set require_envelope_evidence true for field studies that must persist accepted-point +# multi-slice/bottleneck evidence bound exactly to the proposed action identifiers and values. drift: signals: [] settings: {lambda: 0.2, L: 3.5, k: 0.5, h: 6.0, warmup: 30} diff --git a/devtools/neqsim_continuous/production.py b/devtools/neqsim_continuous/production.py index 231817a916d..a7b6a55711d 100644 --- a/devtools/neqsim_continuous/production.py +++ b/devtools/neqsim_continuous/production.py @@ -193,6 +193,135 @@ def _optimizer_evidence_reasons(ctx, proposal): return reasons +def _positive_integer(value): + """Return true only for a positive, finite integer value.""" + return _number(value) and value >= 1 and float(value) == int(value) + + +def _non_negative_integer(value): + """Return true only for a non-negative, finite integer value.""" + return _number(value) and value >= 0 and float(value) == int(value) + + +def _same_finite_vector(first, second): + """Return true for equal-length, exactly equal finite numeric vectors.""" + if not isinstance(first, list) or not isinstance(second, list) or len(first) != len(second): + return False + return all(_number(left) and _number(right) and float(left) == float(right) + for left, right in zip(first, second)) + + +def _envelope_evidence_reasons(ctx, proposal, required=False): + """Validate a persisted accepted-point envelope without trusting its Java origin.""" + if "accepted_point_envelope_evidence" not in proposal: + return ["missing_accepted_point_envelope_evidence"] if required else [] + evidence = proposal.get("accepted_point_envelope_evidence") + if not isinstance(evidence, dict): + return ["invalid_accepted_point_envelope_evidence"] + + reasons = [] + if evidence.get("schema_version") != 1: + reasons.append("unsupported_envelope_schema") + if evidence.get("cycle_id") != ctx.cycle_id: + reasons.append("stale_envelope_evidence") + if evidence.get("optimizer_evidence") != proposal.get("optimizer_evidence"): + reasons.append("envelope_optimizer_evidence_mismatch") + + action_ids = evidence.get("accepted_action_ids") + accepted = evidence.get("accepted_candidate_values") + setpoints = proposal.get("setpoints") + valid_actions = (isinstance(action_ids, list) and action_ids + and all(isinstance(item, str) and item for item in action_ids) + and len(set(action_ids)) == len(action_ids)) + accepted_ids = action_ids if valid_actions else [] + if not valid_actions or not isinstance(accepted, list) or len(accepted) != len(accepted_ids): + reasons.append("invalid_accepted_action_vector") + elif (not isinstance(setpoints, dict) or set(setpoints) != set(accepted_ids) + or not _same_finite_vector([setpoints.get(action_id) for action_id in accepted_ids], accepted)): + reasons.append("envelope_setpoints_mismatch") + + slices = evidence.get("slices") + slice_count = evidence.get("slice_count") + if not _positive_integer(slice_count) or not isinstance(slices, list) or len(slices) != int(slice_count or 0): + reasons.append("invalid_envelope_slice_count") + return reasons + + requested_total = 0 + completed_total = 0 + evaluation_total = 0 + runtime_total = 0.0 + axis_signatures = set() + for index, row in enumerate(slices): + prefix = "envelope_slice_{}".format(index) + if not isinstance(row, dict): + reasons.append(prefix + "_invalid") + continue + if (row.get("study_id") != evidence.get("study_id") + or row.get("candidate_evaluator_id") != evidence.get("candidate_evaluator_id")): + reasons.append(prefix + "_identity_mismatch") + if row.get("complete") is not True or row.get("outcome") != "COMPLETE": + reasons.append(prefix + "_incomplete") + if not _same_finite_vector(row.get("anchor_values"), accepted): + reasons.append(prefix + "_anchor_mismatch") + + axes = row.get("axis_action_ids") + if (not isinstance(axes, list) or not axes + or any(not isinstance(axis, str) or axis not in accepted_ids for axis in axes) + or len(set(axes)) != len(axes)): + reasons.append(prefix + "_invalid_axes") + else: + signature = tuple(axes) + if signature in axis_signatures: + reasons.append("duplicate_envelope_axes") + axis_signatures.add(signature) + + requested = row.get("requested_point_count") + completed = row.get("completed_point_count") + evaluations = row.get("evaluation_count") + runtime = row.get("runtime_seconds") + points = row.get("points") + if (not _positive_integer(requested) or not _positive_integer(completed) + or int(requested) != int(completed) + or not _positive_integer(evaluations) + or not _number(runtime) or runtime < 0 + or not isinstance(points, list) or len(points) != int(completed or 0)): + reasons.append(prefix + "_invalid_counts") + else: + requested_total += int(requested) + completed_total += int(completed) + evaluation_total += int(evaluations) + runtime_total += float(runtime) + if isinstance(points, list): + for point in points: + if (not isinstance(point, dict) or point.get("baseline_restored") is not True + or point.get("baseline_simulation_converged") is not True): + reasons.append(prefix + "_point_recovery_incomplete") + break + if (not isinstance(point.get("candidate_values"), list) + or len(point["candidate_values"]) != len(accepted_ids) + or any(not _number(value) for value in point["candidate_values"])): + reasons.append(prefix + "_invalid_point_candidate") + break + + if (evidence.get("requested_point_count") != requested_total + or evidence.get("completed_point_count") != completed_total + or evidence.get("envelope_evaluation_count") != evaluation_total): + reasons.append("envelope_aggregate_count_mismatch") + envelope_runtime = evidence.get("envelope_runtime_seconds") + if (not _number(envelope_runtime) or envelope_runtime < 0 + or not math.isclose(float(envelope_runtime), runtime_total, rel_tol=1.0e-12, abs_tol=1.0e-12)): + reasons.append("envelope_runtime_mismatch") + optimizer = proposal.get("optimizer_evidence") + optimizer_count = optimizer.get("evaluation_count") if isinstance(optimizer, dict) else None + total_count = evidence.get("total_evaluation_count") + if (not _non_negative_integer(total_count) or not _positive_integer(optimizer_count) + or int(total_count) != int(optimizer_count) + evaluation_total): + reasons.append("envelope_total_evaluation_count_mismatch") + if evidence.get("advisory") is not True: + reasons.append("envelope_not_advisory") + return reasons + + def _non_finite_fields(values): """Return sorted keys whose proposed numeric values are absent or non-finite.""" if not isinstance(values, dict): @@ -234,6 +363,8 @@ def guard(ctx, spec): if not isinstance(proposal.get("objective_kpi"), str) or not proposal.get("objective_kpi"): reasons.append("missing_objective_kpi") reasons.extend(_optimizer_evidence_reasons(ctx, proposal)) + reasons.extend(_envelope_evidence_reasons( + ctx, proposal, required=settings.get("require_envelope_evidence") is True)) for constraint in hard: name = constraint.get("name") or constraint["kpi"] room = slack(constraint, predicted.get(constraint["kpi"])) diff --git a/devtools/test_neqsim_production.py b/devtools/test_neqsim_production.py index b41d112146c..a4ff18b77fe 100644 --- a/devtools/test_neqsim_production.py +++ b/devtools/test_neqsim_production.py @@ -1,4 +1,5 @@ """Tests for the production-optimisation stages (gates, constraints, guard, outcome).""" +import copy import json import math import os @@ -50,21 +51,57 @@ def run(ctx): STAGE_OPTIMIZE = ''' def run(ctx): + optimizer_evidence = { + "cycle_id": ctx.cycle_id, + "simulation_converged": True, + "candidate_feasible": True, + "candidate_finite": True, + "constraint_evidence_complete": True, + "state_restore_complete": True, + "accepted_point_replayed": True, + "actions_complete": True, + "evaluation_count": 12, + "runtime_seconds": 1.25, + } + point = { + "candidate_values": [0.5], + "baseline_restored": True, + "baseline_simulation_converged": True, + } + envelope_evidence = { + "schema_version": 1, + "cycle_id": ctx.cycle_id, + "study_id": "field-envelope", + "candidate_evaluator_id": "field-actions", + "optimizer_evidence": optimizer_evidence, + "accepted_action_ids": ["p3_barg"], + "accepted_candidate_values": [0.5], + "slice_count": 1, + "requested_point_count": 1, + "completed_point_count": 1, + "envelope_evaluation_count": 1, + "envelope_runtime_seconds": 0.1, + "total_evaluation_count": 13, + "slices": [{ + "study_id": "field-envelope", + "candidate_evaluator_id": "field-actions", + "axis_action_ids": ["p3_barg"], + "anchor_values": [0.5], + "requested_point_count": 1, + "completed_point_count": 1, + "evaluation_count": 1, + "runtime_seconds": 0.1, + "outcome": "COMPLETE", + "complete": True, + "points": [point], + }], + "advisory": True, + } base = {"title": "%s", "category": "operational", "setpoints": {"p3_barg": 0.5}, "expected_gain": {"value": 300.0, "kpi": "oil_Sm3d"}, "objective_kpi": "oil_Sm3d", "baseline_value": 12700.0, - "optimizer_evidence": { - "cycle_id": ctx.cycle_id, - "simulation_converged": True, - "candidate_feasible": True, - "candidate_finite": True, - "constraint_evidence_complete": True, - "state_restore_complete": True, - "accepted_point_replayed": True, - "actions_complete": True, - "evaluation_count": 12, - "runtime_seconds": 1.25, - }} + "optimizer_evidence": optimizer_evidence, + "accepted_point_envelope_evidence": envelope_evidence} return {"proposals": [ dict(base, title="safe", predicted={"rvp_bara": 0.63}), dict(base, title="off spec", predicted={"rvp_bara": 0.68}), @@ -83,6 +120,7 @@ def _task(tmp_path, resid=1.0, limit=0.70): (cont / "stages" / "optimize.py").write_text(STAGE_OPTIMIZE) plan = yaml.safe_load((cont / "cycle_plan.yaml").read_text()) plan["gates"] = [{"name": "resid", "kpi": "resid_K", "abs_max": 3.0}] + plan["production"]["require_envelope_evidence"] = True plan["stages"] = [s for s in plan["stages"] if s not in ("sense", "refresh", "agent")] (cont / "cycle_plan.yaml").write_text(yaml.safe_dump(plan)) goal = yaml.safe_load((cont / "goal.yaml").read_text()) @@ -111,7 +149,7 @@ def _guard_context(tmp_path, proposal): def _valid_proposal(): - return { + proposal = { "title": "candidate", "setpoints": {"p3_barg": 0.5}, "expected_gain": {"value": 300.0, "kpi": "oil_Sm3d"}, @@ -131,6 +169,46 @@ def _valid_proposal(): "runtime_seconds": 1.25, }, } + proposal["accepted_point_envelope_evidence"] = _valid_envelope_evidence( + proposal["optimizer_evidence"]) + return proposal + + +def _valid_envelope_evidence(optimizer_evidence): + point = { + "candidate_values": [0.5], + "baseline_restored": True, + "baseline_simulation_converged": True, + } + return { + "schema_version": 1, + "cycle_id": "C-1", + "study_id": "field-envelope", + "candidate_evaluator_id": "field-actions", + "optimizer_evidence": optimizer_evidence, + "accepted_action_ids": ["p3_barg"], + "accepted_candidate_values": [0.5], + "slice_count": 1, + "requested_point_count": 1, + "completed_point_count": 1, + "envelope_evaluation_count": 1, + "envelope_runtime_seconds": 0.1, + "total_evaluation_count": 13, + "slices": [{ + "study_id": "field-envelope", + "candidate_evaluator_id": "field-actions", + "axis_action_ids": ["p3_barg"], + "anchor_values": [0.5], + "requested_point_count": 1, + "completed_point_count": 1, + "evaluation_count": 1, + "runtime_seconds": 0.1, + "outcome": "COMPLETE", + "complete": True, + "points": [point], + }], + "advisory": True, + } def test_cycle_keeps_only_safe_proposals(tmp_path): @@ -149,6 +227,11 @@ def test_cycle_keeps_only_safe_proposals(tmp_path): safe = next(item for item in items if item["title"] == "safe") assert safe["optimizer_evidence"]["cycle_id"] == manifest["cycle_id"] assert safe["optimizer_evidence"]["accepted_point_replayed"] is True + envelope = safe["accepted_point_envelope_evidence"] + assert envelope["cycle_id"] == manifest["cycle_id"] + assert envelope["accepted_action_ids"] == ["p3_barg"] + assert envelope["accepted_candidate_values"] == [0.5] + assert envelope["total_evaluation_count"] == 13 @pytest.mark.parametrize("field, reason", [ @@ -172,6 +255,7 @@ def test_guard_rejects_incomplete_optimizer_evidence(tmp_path, field, reason): def test_guard_rejects_missing_and_stale_optimizer_evidence(tmp_path): missing = _valid_proposal() missing.pop("optimizer_evidence") + missing.pop("accepted_point_envelope_evidence") stale = _valid_proposal() stale["title"] = "stale" stale["optimizer_evidence"]["cycle_id"] = "C-0" @@ -183,6 +267,114 @@ def test_guard_rejects_missing_and_stale_optimizer_evidence(tmp_path): assert reasons == {"candidate": ["missing_optimizer_evidence"], "stale": ["stale_candidate"]} +def test_guard_keeps_optimizer_only_proposal_when_envelope_is_optional(tmp_path): + proposal = _valid_proposal() + proposal.pop("accepted_point_envelope_evidence") + ctx = _guard_context(tmp_path, proposal) + production.guard(ctx, {}) + assert [row["title"] for row in ctx.proposals] == ["candidate"] + + +def test_guard_requires_envelope_when_configured(tmp_path): + proposal = _valid_proposal() + proposal.pop("accepted_point_envelope_evidence") + ctx = _guard_context(tmp_path, proposal) + ctx.plan["production"]["require_envelope_evidence"] = True + production.guard(ctx, {}) + assert ctx.proposals == [] + assert json.loads((tmp_path / "guard.json").read_text())["withheld"][0]["reasons"] == [ + "missing_accepted_point_envelope_evidence" + ] + + +def test_guard_rejects_stale_or_internally_inconsistent_envelope(tmp_path): + variants = [] + + stale = _valid_proposal() + stale["title"] = "stale envelope" + stale["accepted_point_envelope_evidence"]["cycle_id"] = "C-0" + variants.append(stale) + + optimizer_mismatch = _valid_proposal() + optimizer_mismatch["title"] = "optimizer mismatch" + optimizer_mismatch["accepted_point_envelope_evidence"]["optimizer_evidence"] = dict( + optimizer_mismatch["optimizer_evidence"], evaluation_count=11) + variants.append(optimizer_mismatch) + + setpoint_mismatch = _valid_proposal() + setpoint_mismatch["title"] = "setpoint mismatch" + setpoint_mismatch["accepted_point_envelope_evidence"]["accepted_candidate_values"] = [0.6] + variants.append(setpoint_mismatch) + + partial_slice = _valid_proposal() + partial_slice["title"] = "partial slice" + partial_slice["accepted_point_envelope_evidence"]["slices"][0]["complete"] = False + variants.append(partial_slice) + + partial_recovery = _valid_proposal() + partial_recovery["title"] = "partial recovery" + partial_recovery["accepted_point_envelope_evidence"]["slices"][0]["points"][0][ + "baseline_restored"] = False + variants.append(partial_recovery) + + count_mismatch = _valid_proposal() + count_mismatch["title"] = "count mismatch" + count_mismatch["accepted_point_envelope_evidence"]["completed_point_count"] = 2 + variants.append(count_mismatch) + + duplicate_axes = _valid_proposal() + duplicate_axes["title"] = "duplicate axes" + duplicate_slice = copy.deepcopy(duplicate_axes["accepted_point_envelope_evidence"]["slices"][0]) + duplicate_axes["accepted_point_envelope_evidence"].update({ + "slice_count": 2, + "requested_point_count": 2, + "completed_point_count": 2, + "envelope_evaluation_count": 2, + "envelope_runtime_seconds": 0.2, + "total_evaluation_count": 14, + }) + duplicate_axes["accepted_point_envelope_evidence"]["slices"].append(duplicate_slice) + variants.append(duplicate_axes) + + ctx = _guard_context(tmp_path, variants[0]) + ctx.plan["production"]["require_envelope_evidence"] = True + ctx.proposals.extend(variants[1:]) + production.guard(ctx, {}) + assert ctx.proposals == [] + reasons = {row["title"]: row["reasons"] + for row in json.loads((tmp_path / "guard.json").read_text())["withheld"]} + assert "stale_envelope_evidence" in reasons["stale envelope"] + assert "envelope_optimizer_evidence_mismatch" in reasons["optimizer mismatch"] + assert "envelope_setpoints_mismatch" in reasons["setpoint mismatch"] + assert "envelope_slice_0_incomplete" in reasons["partial slice"] + assert "envelope_slice_0_point_recovery_incomplete" in reasons["partial recovery"] + assert "envelope_aggregate_count_mismatch" in reasons["count mismatch"] + assert "duplicate_envelope_axes" in reasons["duplicate axes"] + + +def test_guard_fails_closed_for_malformed_envelope_types(tmp_path): + malformed = _valid_proposal() + malformed["title"] = "malformed envelope" + malformed["accepted_point_envelope_evidence"] = [] + malformed_actions = _valid_proposal() + malformed_actions["title"] = "malformed actions" + malformed_actions["accepted_point_envelope_evidence"]["accepted_action_ids"] = 7 + malformed_optimizer = _valid_proposal() + malformed_optimizer["title"] = "malformed optimizer" + malformed_optimizer["optimizer_evidence"] = "not a mapping" + + ctx = _guard_context(tmp_path, malformed) + ctx.plan["production"]["require_envelope_evidence"] = True + ctx.proposals.extend([malformed_actions, malformed_optimizer]) + production.guard(ctx, {}) + assert ctx.proposals == [] + reasons = {row["title"]: row["reasons"] + for row in json.loads((tmp_path / "guard.json").read_text())["withheld"]} + assert reasons["malformed envelope"] == ["invalid_accepted_point_envelope_evidence"] + assert "invalid_accepted_action_vector" in reasons["malformed actions"] + assert "missing_optimizer_evidence" in reasons["malformed optimizer"] + + @pytest.mark.parametrize("field, value, reason", [ ("evaluation_count", 0, "invalid_evaluation_count"), ("evaluation_count", 1.5, "invalid_evaluation_count"), diff --git a/docs/development/CONTINUOUS_TASK_SOLVING.md b/docs/development/CONTINUOUS_TASK_SOLVING.md index 8f58cfe07c9..810be173798 100644 --- a/docs/development/CONTINUOUS_TASK_SOLVING.md +++ b/docs/development/CONTINUOUS_TASK_SOLVING.md @@ -935,8 +935,8 @@ The loop is **advisory**: nothing is written to the control system, and every ac | `model_update` (your script) | Reads live data, updates and runs the model, returns KPIs and the residuals against the measurements | | `gates` | `gates:` in the plan (`{name, kpi, abs_max\|max\|min}`). A failed or missing KPI blocks all advice (`model_gate:`) | | `constraints` | `constraints:` in `goal.yaml` (`{name, kpi, op, limit, margin, warn, hard, source}`). `margin` tightens the limit. A hard constraint with no `limit` is *unconfirmed* and blocks all advice | -| `optimize` (your script) | Searches the levers and returns `proposals` with `setpoints`, `expected_gain`, `predicted` KPIs, `objective_kpi`, `baseline_value`, and the fail-closed `optimizer_evidence` below | -| `guard` | Keeps a proposal only if its numeric data are finite, its evidence belongs to the current cycle and proves a fully validated/replayed candidate, it has a gain above `production.min_gain`, and every hard constraint is predicted inside its margin. `guard.json` lists the reasons for every withheld proposal | +| `optimize` (your script) | Searches the levers and returns `proposals` with `setpoints`, `expected_gain`, `predicted` KPIs, `objective_kpi`, `baseline_value`, and the fail-closed `optimizer_evidence` below; field studies may also return `accepted_point_envelope_evidence` | +| `guard` | Keeps a proposal only if its numeric data are finite, its evidence belongs to the current cycle and proves a fully validated/replayed candidate, it has a gain above `production.min_gain`, and every hard constraint is predicted inside its margin. When `production.require_envelope_evidence` is `true`, the guard also requires a complete accepted-point envelope bound exactly to the proposal's action identifiers and values. `guard.json` lists the reasons for every withheld proposal | | `outcome` | For ledger items in status `implemented`: realised gain against predicted gain (`outcome_confirmed:` / `outcome_miss:`) | `demonstrated_limit(values, quantile=0.99, design=...)` gives an equipment limit from operating experience: the @@ -966,6 +966,19 @@ optimizer_evidence: item retains this block, including the positive integer evaluation count and finite non-negative runtime, so resume and change-driven cycles preserve the incumbent's validation and performance provenance. +For field-to-facility studies, set `production.require_envelope_evidence: true` and attach the map from +`ProcessModelOperatingEnvelopeStudy.getAcceptedPointEnvelopeEvidence(...)` as +`accepted_point_envelope_evidence`. The guard revalidates the persisted JSON instead of trusting its Java origin: +the cycle and optimizer block must match; ordered `accepted_action_ids` and +`accepted_candidate_values` must bind exactly to `setpoints`; slice study/evaluator identities and anchors must +match; ordered axis sets must be unique; every slice must be complete; every point must have restored and +reconverged the baseline; and point, evaluation and runtime totals must reconcile. Exploratory points may be +physically infeasible or fail candidate convergence—the envelope records those boundaries—but they cannot leave +the live model partially restored. The ordinary production template leaves this requirement disabled for backward +compatibility; a supplied envelope is always validated strictly. Accepted proposals retain the complete block in +the existing append-only ledger, so resume and evidence-change cycles do not depend on chat history or a second +state store. + ### 17.2 Levers: every operator-adjustable parameter Levers are listed under `production.levers` in the plan. A lever is any setting the operators can change: diff --git a/docs/integration/EXTERNAL_OPTIMIZER_INTEGRATION.md b/docs/integration/EXTERNAL_OPTIMIZER_INTEGRATION.md index ceefb69c4e7..09be5042f8f 100644 --- a/docs/integration/EXTERNAL_OPTIMIZER_INTEGRATION.md +++ b/docs/integration/EXTERNAL_OPTIMIZER_INTEGRATION.md @@ -1094,6 +1094,14 @@ study plus complete, uniquely addressed slices anchored at the accepted candidat foreign, deserialized, duplicate-axis, incomplete-recovery, stale, or definition-drifted evidence rather than combining it into a current cycle. +The block also carries ordered `accepted_action_ids` beside `accepted_candidate_values`. Attach it to +the proposal as `accepted_point_envelope_evidence` and set +`production.require_envelope_evidence: true` in a continuous field-study plan. The existing living-task guard +then verifies exact action/setpoint binding, optimizer/cycle identity, slice identity and anchors, complete +baseline recovery, unique axes, and reconciled point/evaluation/runtime totals before the existing ledger stores +the proposal. A later route, limit or evidence revision must run a new cycle and produce a fresh block; a stale +cycle id, mismatched action vector, or copied partial slice is withheld rather than resumed as a current candidate. + ### Search a fixed-total continuous allocation `ProcessModelAllocationOptimizer` composes the atomic evaluator when all allocation actions are diff --git a/src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudy.java b/src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudy.java index 344e914a0a8..8e19b436997 100644 --- a/src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudy.java +++ b/src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudy.java @@ -234,6 +234,11 @@ public Map getAcceptedPointEnvelopeEvidence(String cycleId, Oper evidence.put("study_provenance", provenance); evidence.put("candidate_evaluator_id", evaluator.getId()); evidence.put("optimizer_evidence", search.getOptimizerEvidence(currentCycleId)); + List acceptedActionIds = new ArrayList(); + for (ProcessModelOperatingAction action : evaluator.getActions()) { + acceptedActionIds.add(action.getId()); + } + evidence.put("accepted_action_ids", Collections.unmodifiableList(acceptedActionIds)); evidence.put("accepted_candidate_values", toFiniteDoubleList(accepted)); evidence.put("accepted_point_bottleneck", acceptedBottleneck == null ? null : acceptedBottleneck.toEvidenceMap()); evidence.put("slice_count", Integer.valueOf(slices.length)); diff --git a/src/main/resources/neqsim/mcp/engineering-coverage.json b/src/main/resources/neqsim/mcp/engineering-coverage.json index 980727b8513..f86f8672ab9 100644 --- a/src/main/resources/neqsim/mcp/engineering-coverage.json +++ b/src/main/resources/neqsim/mcp/engineering-coverage.json @@ -4064,7 +4064,7 @@ {"id":"neqsim.process.util.optimizer.ProcessModelOperatingAction","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingAction.java","sourceDigest":"75b70fd0df57e49ba6339f1f1c5959156be74d4a5576c271edd5df053655a2a6","domain":"process","skills":["neqsim-production-optimization"],"agents":["field-development"],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.ProcessModelOperatingActionEvaluator","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingActionEvaluator.java","sourceDigest":"f7d49d59ba7ff25752fc92a54e5599369cb57d5f9c5acd5e6c6db7b4ef6fe206","domain":"process","skills":["neqsim-production-optimization"],"agents":["field-development"],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.ProcessModelOperatingActionSetEvaluator","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingActionSetEvaluator.java","sourceDigest":"2f0fc96599a6942b3be0b4be9e0433b5fd462245b30e83c1d33cb6ba344f4562","domain":"process","skills":["neqsim-production-optimization","neqsim-troubleshooting"],"agents":["dynamic-equipment","field-development","gas-processing-refining","neqsim-test","notebook-example","process-model","reaction-engineering","root-cause","rotating-equipment","solve-process","solve-task","thermo-fluid","unisim-reader"],"capabilities":[],"disposition":"review_required"}, - {"id":"neqsim.process.util.optimizer.ProcessModelOperatingEnvelopeStudy","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudy.java","sourceDigest":"1313e080660ec3e022eaa4cd682ac1554734560fb4289b5d05d7542f9ab59f59","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, + {"id":"neqsim.process.util.optimizer.ProcessModelOperatingEnvelopeStudy","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudy.java","sourceDigest":"dd3999d1c0a9492a4b8c16f8bd11916e7cd9fbc99abaa166ba002a2ff2ac60be","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.ProcessModelOperatingPointOptimizer","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProcessModelOperatingPointOptimizer.java","sourceDigest":"ea5956b29f6f868f6c0988307b7eccd8b01b5071782ad863efaabf712e11090c","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.ProcessModelOptimizationView","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProcessModelOptimizationView.java","sourceDigest":"b0b584469d2eda3b8fab8de4c3728e58b5ff0a39105a8090f2b764dd98de7a0c","domain":"process","skills":[],"agents":[],"capabilities":[],"disposition":"review_required"}, {"id":"neqsim.process.util.optimizer.ProcessModelSimulationEvaluator","kind":"class","source":"src/main/java/neqsim/process/util/optimizer/ProcessModelSimulationEvaluator.java","sourceDigest":"f707f4f686c226abd947618198f09596b8c0f3f07bdd86bdefb850b00608b063","domain":"process","skills":["neqsim-production-optimization"],"agents":["field-development"],"capabilities":[],"disposition":"review_required"}, diff --git a/src/test/java/neqsim/process/util/optimizer/FieldToFacilityOptimizationAcceptanceTest.java b/src/test/java/neqsim/process/util/optimizer/FieldToFacilityOptimizationAcceptanceTest.java index 6d4e07087e6..2d2f982b506 100644 --- a/src/test/java/neqsim/process/util/optimizer/FieldToFacilityOptimizationAcceptanceTest.java +++ b/src/test/java/neqsim/process/util/optimizer/FieldToFacilityOptimizationAcceptanceTest.java @@ -303,6 +303,9 @@ void evaluatesTenWellExportBoundaryWithCompleteConstraintEvidence() { Map envelopeEvidence = acceptedPointStudy.getAcceptedPointEnvelopeEvidence("synthetic-cycle-1", result, acceptedSlice, routingSlice); assertEquals(evaluationsBeforeExport, simulation.getEvaluationCount()); + assertEquals(actions.stream().map(ProcessModelOperatingAction::getId).collect(java.util.stream.Collectors.toList()), + envelopeEvidence.get("accepted_action_ids")); + assertEquals(actions.size(), ((List) envelopeEvidence.get("accepted_candidate_values")).size()); assertEquals(Integer.valueOf(2), envelopeEvidence.get("slice_count")); assertEquals(Integer.valueOf(4), envelopeEvidence.get("requested_point_count")); assertEquals(Integer.valueOf(4), envelopeEvidence.get("completed_point_count")); diff --git a/src/test/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudyTest.java b/src/test/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudyTest.java index 22155375915..23117c07d90 100644 --- a/src/test/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudyTest.java +++ b/src/test/java/neqsim/process/util/optimizer/ProcessModelOperatingEnvelopeStudyTest.java @@ -282,6 +282,9 @@ void acceptedPointReplayAnchorsBottleneckAndEnvelopeFailClosed() throws Exceptio assertEquals(evaluationsBeforeEvidence, evaluator.getSimulationEvaluator().getEvaluationCount()); assertEquals(Integer.valueOf(1), envelopeEvidence.get("schema_version")); assertEquals("cycle-43", envelopeEvidence.get("cycle_id")); + assertEquals(Arrays.asList("producer-a-rate", "producer-b-rate"), envelopeEvidence.get("accepted_action_ids")); + assertEquals(Arrays.asList(Double.valueOf(accepted[0]), Double.valueOf(accepted[1])), + envelopeEvidence.get("accepted_candidate_values")); assertEquals(Integer.valueOf(2), envelopeEvidence.get("slice_count")); assertEquals(Long.valueOf(4L), envelopeEvidence.get("envelope_evaluation_count")); assertEquals(Long.valueOf(search.getEvaluationCount() + 4L), envelopeEvidence.get("total_evaluation_count")); From d469208aa227449d40c3f441e8f5a345795aa29a Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:29:53 +0100 Subject: [PATCH 2/3] test(devtools): isolate evidence catalogs and cover production guards in CI Replace environment-dependent agent assumptions with a temporary synthetic catalog exercised through real discovery and parsing. Cover absent/plugin-only catalogs, aliases, malformed and duplicate cues, and invalid evidence status. Run the production-envelope and persistent-ledger regression suite in the Devtools workflow. Record the repair in the task log. --- .github/workflows/devtools_tests.yml | 1 + devtools/test_evidence_sources.py | 102 +++++++++++++++++++++++++-- docs/development/TASK_LOG.md | 6 ++ 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/.github/workflows/devtools_tests.yml b/.github/workflows/devtools_tests.yml index d3a79b4c177..e919c3c0ba8 100644 --- a/.github/workflows/devtools_tests.yml +++ b/.github/workflows/devtools_tests.yml @@ -54,6 +54,7 @@ jobs: devtools/test_file_neqsim_issue.py \ devtools/test_task_publication_schedule.py \ devtools/test_neqsim_continuous.py \ + devtools/test_neqsim_production.py \ devtools/test_neqsim_living_tasks.py \ devtools/test_neqsim_doctor.py \ devtools/test_unisim_outputs.py \ diff --git a/devtools/test_evidence_sources.py b/devtools/test_evidence_sources.py index 1491b52c572..8f9826674ca 100644 --- a/devtools/test_evidence_sources.py +++ b/devtools/test_evidence_sources.py @@ -1,23 +1,73 @@ -"""Task-level regression tests for equipment evidence routing warnings.""" +"""Hermetic evidence routing tests with a synthetic installed agent catalog. +The cue declarations below are test inputs, not a copy of an enterprise catalog. +Exercise real discovery, parsing and validation without depending on optional agents +installed on the developer's machine or on the CI runner. +""" + +import os import tempfile import unittest +from contextlib import ExitStack from pathlib import Path +from unittest import mock import validate_task_results as validator -PORTAL = "enterprise-equipment-portal-agent" -SEMI = "enterprise-semi-search-agent" +PORTAL = "fixture-equipment-portal-agent" +SEMI = "fixture-vendor-documents-agent" +GEOX = "fixture-prospect-agent" +GEOX_ALIAS = "fixture-prospect-legacy-agent" +STID = "fixture-plant-documents-agent" + +# Deliberately small patterns for the contract cases below. Real routing policy +# belongs to the provider's agent definitions, never to this public validator. +TIEBACK_CUE = r"\b(?:vpbo|tie-?back|development solution)\b" +CATALOG = { + PORTAL: (r"\b(?:equipment[ -]portal|u[14]00|sps handbook|surf (?:capex|cost)|" + r"(?:satellite|template) cost|flowline (?:reference price|technology)|" + r"subsea-system technology|design-to-cost)\b|" + TIEBACK_CUE), + SEMI: (r"\b(?:vendor[ -](?:data[ -]?sheet|manual)|manufacturer (?:catalogue|document)|" + r"semi[ -](?:library|search)|semi\.equinor\.com|demister k-factor|inlet device)\b" + r"|(?:smi|sdi)[_-]\d+"), + GEOX: r"\bapbo\b", + STID: TIEBACK_CUE, +} class EvidenceSourceTest(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) - self.task = Path(self.temp.name) + self.root = Path(self.temp.name) + self.task = self.root / "task" + self.task.mkdir() + self.agents = self.root / "installed-agents" + self.plugins = self.root / "plugins" + self.repo = self.root / "workspace" / "neqsim" + contexts = ExitStack() + self.addCleanup(contexts.close) + contexts.enter_context(mock.patch.dict(os.environ, { + "NEQSIM_AGENTS_HOME": str(self.agents), + "NEQSIM_AGENT_PLUGINS_HOME": str(self.plugins), + })) + # The validator derives checkout/sibling roots from its own file path. + # Redirect that boundary too, leaving discovery and cue loading intact. + contexts.enter_context(mock.patch.object( + validator, "__file__", str(self.repo / "devtools" / "validate_task_results.py"))) + for agent, cue in CATALOG.items(): + declaration = "{} :: {}\n".format(agent, cue) + if agent == GEOX: + declaration += "alias {} = {}\n".format(GEOX_ALIAS, GEOX) + self.write_agent(self.agents / agent / "AGENT.md", declaration) self.results = {"uncertainty": {"method": "sensitivity"}} + def write_agent(self, path, declaration): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("# Synthetic test agent\n\n```evidence-source-cues\n" + + declaration + "```\n", encoding="utf-8") + def warnings_for(self, prompt): (self.task / "user_input.md").write_text(prompt, encoding="utf-8") return validator.check_evidence_sources(self.task, self.results) @@ -77,20 +127,20 @@ def test_accounted_sources_and_quick_tasks_do_not_warn(self): self.assertEqual(self.warnings_for("SURF cost and vendor manual"), []) def test_geox_cue_accepts_either_geox_agent_name(self): - for name in ("enterprise-geox-computer-use-agent", "enterprise-geox-agent"): + for name in (GEOX, GEOX_ALIAS): with self.subTest(name=name): self.results["agent_workflow_plan"] = {"evidence_sources": [ {"agent": name, "status": "no_access", "reason": "needs a signed-in desktop session"}]} self.assertEqual(self.warnings_for("APbo prospect chance of success"), []) self.results["agent_workflow_plan"] = {} - self.assertIn("enterprise-geox-computer-use-agent", " ".join(self.warnings_for("APbo prospect"))) + self.assertIn(GEOX, " ".join(self.warnings_for("APbo prospect"))) def test_tieback_and_vpbo_tasks_cue_portal_and_stid(self): for prompt in ("Mistral Nord VPbo onboarding", "subsea tie-back to a host", "tieback of a discovery"): with self.subTest(prompt=prompt): warnings = " ".join(self.warnings_for(prompt)) self.assertIn(PORTAL, warnings) - self.assertIn("enterprise-stid-reader-agent", warnings) + self.assertIn(STID, warnings) self.assertIn(PORTAL, " ".join(self.warnings_for("development solution for a discovery"))) def test_task_spec_is_checked_and_only_missing_agent_is_reported(self): @@ -102,6 +152,44 @@ def test_task_spec_is_checked_and_only_missing_agent_is_reported(self): self.assertIn(SEMI, warnings) self.assertNotIn(PORTAL, warnings) + def test_no_installed_catalog_has_no_provider_warnings(self): + with mock.patch.dict(os.environ, {"NEQSIM_AGENTS_HOME": str(self.root / "absent")}): + self.assertEqual(validator.load_evidence_source_cues(), ([], {})) + self.assertEqual(self.warnings_for("SURF cost, vendor manual and APbo prospect"), []) + + def test_plugin_only_catalog_supplies_cues_and_aliases(self): + agent_path = (self.plugins / "fixture-plugin" / "com.github.copilot" / + "agents" / "fixture.agent.md") + self.write_agent(agent_path, "{} :: {}\nalias {} = {}\n".format( + GEOX, CATALOG[GEOX], GEOX_ALIAS, GEOX)) + with mock.patch.dict(os.environ, {"NEQSIM_AGENTS_HOME": str(self.root / "absent")}): + self.assertEqual(validator.load_evidence_source_cues(), + ([(GEOX, CATALOG[GEOX])], {GEOX_ALIAS: GEOX})) + self.assertIn(GEOX, " ".join(self.warnings_for("APbo prospect"))) + self.results["agent_workflow_plan"] = {"agents_used": [GEOX_ALIAS]} + self.assertEqual(self.warnings_for("APbo prospect"), []) + + def test_duplicate_cues_and_invalid_regex_do_not_hide_valid_warning(self): + self.write_agent(self.repo / ".github" / "agents" / "fixture.agent.md", + "invalid-agent :: [\n{} :: {}\n".format(PORTAL, CATALOG[PORTAL])) + cues, _ = validator.load_evidence_source_cues() + self.assertEqual(cues.count((PORTAL, CATALOG[PORTAL])), 1) + self.assertNotIn("invalid-agent", [agent for agent, _ in cues]) + self.assertEqual(" ".join(self.warnings_for("SURF cost")).count(PORTAL), 1) + + def test_unknown_status_still_warns_for_accounted_agent(self): + self.results["agent_workflow_plan"] = {"evidence_sources": [ + {"agent": PORTAL, "status": "unknown", "reason": "Synthetic invalid decision"}, + ]} + warnings = " ".join(self.warnings_for("SURF cost")) + self.assertIn("unknown status", warnings) + self.assertIn(PORTAL, warnings) + + def test_unavailable_discovery_is_best_effort(self): + with mock.patch("agent_search._discover_roots", side_effect=OSError("unavailable")): + self.assertEqual(validator.load_evidence_source_cues(), ([], {})) + self.assertEqual(self.warnings_for("SURF cost"), []) + if __name__ == "__main__": unittest.main() diff --git a/docs/development/TASK_LOG.md b/docs/development/TASK_LOG.md index 7e586587026..27f7cb827a5 100644 --- a/docs/development/TASK_LOG.md +++ b/docs/development/TASK_LOG.md @@ -36,6 +36,12 @@ requirement`, or `confidential compressor route`. +### 2026-10-14 — Isolate evidence routing tests from optional agent installations +**Type:** G (Workflow) +**Keywords:** evidence-source-cues, hermetic tests, plugin discovery, production guard, CI, PR 4312 +**Solution:** `devtools/test_evidence_sources.py` and `.github/workflows/devtools_tests.yml`. +**Notes:** The validator loads cue declarations from discovered agents; public CI has no optional provider catalog. Tests now supply a temporary synthetic catalog and isolate checkout, installed-agent and plugin roots while exercising real discovery and parsing. Cover missing catalogs, plugin-only aliases, duplicate/invalid cues and invalid statuses. Keep provider routing policy out of the validator. Include the production-envelope guard and persistent-ledger regression suite in Devtools CI. + ### 2026-10-11 — Review source routing and reusable tie-back guidance **Type:** E (Feature) **Keywords:** source attribution, evidence routing, duplicate manifest, VFP, host model, regression From ac80719e08d28d39d5890da94cbc5967382ee685 Mon Sep 17 00:00:00 2001 From: Even Solbraa <41290109+EvenSol@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:27:36 +0200 Subject: [PATCH 3/3] fix(workflow): revalidate resumed proposals against current guard --- devtools/neqsim_continuous/cycle.py | 16 ++- devtools/neqsim_continuous/production.py | 8 ++ devtools/neqsim_continuous/stages.py | 14 ++ devtools/neqsim_continuous/user_input.py | 17 +++ devtools/test_neqsim_production.py | 137 ++++++++++++++++++++ docs/development/CONTINUOUS_TASK_SOLVING.md | 15 ++- docs/development/TASK_LOG.md | 6 + 7 files changed, 208 insertions(+), 5 deletions(-) diff --git a/devtools/neqsim_continuous/cycle.py b/devtools/neqsim_continuous/cycle.py index a427b92f60f..fba127cbbf8 100644 --- a/devtools/neqsim_continuous/cycle.py +++ b/devtools/neqsim_continuous/cycle.py @@ -3,7 +3,8 @@ A cycle runs the planned stages in order, writes an immutable folder ``continuous/cycles//`` and a ``cycle.json`` manifest, and never raises for a stage failure: the failure is recorded, dependants are skipped and the cycle is marked -``degraded``. An interrupted cycle resumes at its first incomplete stage. +``degraded``. An interrupted cycle reuses completed work and revalidates production +gates, constraints and proposals before publishing advice. """ import csv @@ -27,6 +28,9 @@ "gates": ["kpis"], "constraints": ["kpis"], "guard": ["gates", "constraints"], "ledger": ["guard"], "outcome": ["kpis"]} LOCK_HOURS = 6.0 +# These cheap checks mutate in-memory blocks/proposals, not model or optimizer state. +# Restoring their status alone would lose the filtering performed before interruption. +REVALIDATE_ON_RESUME = ("gates", "constraints", "guard") def _utc(value=None): @@ -59,6 +63,7 @@ def __init__(self, task_dir, plan, goal, baseline, now, mode, cycle_id, cycle_di self.solve, self.stop_state, self.digest_text = {}, None, "" self.notifications, self.agent_run, self.previous_kpis = [], {}, {} self.guard_blocks, self.sections = [], [] + self.guarded_proposals = set() self.user_levers = {} self.evidence_analysis = evidence_analysis @@ -230,7 +235,7 @@ def run_cycle(task_dir, mode="monitor", now=None, stages=None, dry_run=False, no spec = dict(spec, name=name) started = time.time() proposal_start = len(ctx.proposals) - if name in done: + if name in done and name not in REVALIDATE_ON_RESUME: saved = done[name] result = StageResult(**{k: v for k, v in saved.items() if k in ( "name", "status", "outputs", "kpis", "triggers", "message")}) @@ -238,6 +243,13 @@ def run_cycle(task_dir, mode="monitor", now=None, stages=None, dry_run=False, no _builtin_stages.restore_refresh(ctx, result) elif name == "evidence": _builtin_stages.restore_evidence(ctx) + elif name == "inputs": + try: + _user_input_stage.restore_inputs(ctx) + except Exception as error: + ctx.guard_blocks.append("user_input_resume_state_invalid") + result = StageResult(name, "fail", message="{}: {}".format( + type(error).__name__, error)) if name.startswith("script:") or name == "kpis": ctx.kpis.update(result.kpis) if saved.get("solve"): diff --git a/devtools/neqsim_continuous/production.py b/devtools/neqsim_continuous/production.py index a7b6a55711d..edbab69b539 100644 --- a/devtools/neqsim_continuous/production.py +++ b/devtools/neqsim_continuous/production.py @@ -16,6 +16,7 @@ The optimiser itself stays a task-local ``script:`` stage that returns ``proposals``. """ +import json import math import os @@ -329,8 +330,14 @@ def _non_finite_fields(values): return sorted(str(key) for key, value in values.items() if not _number(value)) +def _proposal_snapshot(proposal): + """Bind publication to exact JSON content, including evidence boolean types.""" + return json.dumps(proposal, sort_keys=True, separators=(",", ":")) + + def guard(ctx, spec): """Keep only proposals that are safe to show; record why the others were withheld.""" + ctx.guarded_proposals = set() settings = ctx.plan.get("production", {}) or {} configured_min_gain = settings.get("min_gain", 0.0) invalid_min_gain = not _number(configured_min_gain) @@ -377,6 +384,7 @@ def guard(ctx, spec): else: accepted.append(dict(proposal, requires_approval=True, guard="passed")) ctx.proposals = accepted + ctx.guarded_proposals = {_proposal_snapshot(proposal) for proposal in accepted} write_json(os.path.join(ctx.cycle_dir, "guard.json"), {"accepted": [p.get("title") for p in accepted], "withheld": withheld, "blocks": list(ctx.guard_blocks)}) _section(ctx, "Proposals withheld", ["{} ({})".format(w["title"], ", ".join(w["reasons"])) for w in withheld]) diff --git a/devtools/neqsim_continuous/stages.py b/devtools/neqsim_continuous/stages.py index dae30c5610c..894d617077f 100644 --- a/devtools/neqsim_continuous/stages.py +++ b/devtools/neqsim_continuous/stages.py @@ -358,6 +358,20 @@ def ledger(ctx, spec): # Keep a fallback for custom plans that supply evidence analysis without an # evidence stage. The normal path invalidates before optimization starts. invalidates, invalidated = _invalidate_changed_evidence(ctx) + optimizer_proposals = [proposal for proposal in ctx.proposals + if isinstance(proposal, dict) and ( + "optimizer_evidence" in proposal or "accepted_point_envelope_evidence" in proposal)] + guard_result = ctx.stage_results.get("guard") + if optimizer_proposals or (ctx.proposals and guard_result is not None): + from .production import _proposal_snapshot + + guarded = getattr(ctx, "guarded_proposals", set()) + if (guard_result is None or guard_result.status not in ("ok", "warn") + or any(proposal.get("guard") != "passed" or proposal.get("requires_approval") is not True + or _proposal_snapshot(proposal) not in guarded + for proposal in ctx.proposals)): + return StageResult("ledger", "fail", triggers=["unguarded_optimizer_proposal"], + message="Optimizer evidence must match a proposal accepted by the current-cycle guard") book = Ledger(os.path.join(ctx.state_dir, "ledger", "events.jsonl")) items = book.current() open_items = {key: value for key, value in items.items() diff --git a/devtools/neqsim_continuous/user_input.py b/devtools/neqsim_continuous/user_input.py index 8f2b1c272a3..f84baf852fa 100644 --- a/devtools/neqsim_continuous/user_input.py +++ b/devtools/neqsim_continuous/user_input.py @@ -184,6 +184,23 @@ def _apply(ctx, entry, warnings): return applied +def restore_inputs(ctx): + """Reapply this cycle's frozen restrictions without repeating input side effects.""" + report = read_json(os.path.join(ctx.cycle_dir, "user_input.json")) + if not isinstance(report, dict) or not isinstance(report.get("active"), list): + raise ValueError("Completed input stage is missing its frozen active-input snapshot") + active = report["active"] + if any(not isinstance(entry, dict) for entry in active): + raise ValueError("Completed input stage contains malformed active inputs") + ctx.user_levers = {} + warnings = [] + for entry in active: + _apply(ctx, entry, warnings) + ctx.sections.append("User input in force: " + ("; ".join(describe(e) for e in active) if active else "none")) + if warnings: + ctx.sections.append("User input warnings: " + "; ".join(warnings)) + + def inputs(ctx, spec): """Apply the engineers' comments and restrictions for this cycle.""" ctx.user_levers = {} diff --git a/devtools/test_neqsim_production.py b/devtools/test_neqsim_production.py index fca3287f6e0..7b21d6ffc01 100644 --- a/devtools/test_neqsim_production.py +++ b/devtools/test_neqsim_production.py @@ -577,6 +577,143 @@ def test_dry_run_does_not_invalidate_persisted_evidence(tmp_path): assert book.events() == before +def _interrupt_after_stage(task, stage): + """Freeze a cycle at a completed stage before later publication side effects.""" + plan = yaml.safe_load((pathlib.Path(task) / "continuous" / "cycle_plan.yaml").read_text()) + selected = plan["stages"][:plan["stages"].index(stage) + 1] + completed = run_cycle(task, stages=selected, no_agent=True) + cycle_path = pathlib.Path(task) / "continuous" / "cycles" / completed["cycle_id"] / "cycle.json" + interrupted = json.loads(cycle_path.read_text(encoding="utf-8")) + interrupted["status"] = "running" + interrupted.pop("finished_at", None) + cycle_path.write_text(json.dumps(interrupted), encoding="utf-8") + return completed, cycle_path.parent + + +def test_resume_after_guard_revalidates_filtered_proposals_before_ledger(tmp_path): + task = _task(tmp_path) + first, cycle = _interrupt_after_stage(task, "guard") + initial = json.loads((cycle / "guard.json").read_text()) + assert initial["accepted"] == ["safe"] + assert {row["title"] for row in initial["withheld"]} == {"off spec", "no prediction", "no gain"} + # The expensive model/optimizer stages must remain checkpointed. + (pathlib.Path(task) / "continuous" / "stages" / "optimize.py").write_text( + "def run(ctx):\n raise AssertionError('completed optimizer was rerun')\n") + + resumed = run_cycle(task, no_agent=True) + + assert resumed["cycle_id"] == first["cycle_id"] + assert resumed["degraded"] is False + assert json.loads((cycle / "guard.json").read_text()) == initial + items = list(Ledger(pathlib.Path(task) / "continuous" / "ledger" / "events.jsonl").current().values()) + assert [item["title"] for item in items] == ["safe"] + assert items[0]["guard"] == "passed" + assert items[0]["requires_approval"] is True + assert items[0]["evidence_current"] is True + + +@pytest.mark.parametrize("stage,resid,limit,block", [ + ("gates", 4.0, 0.70, "model_gate:resid"), + ("constraints", 4.0, 0.70, "model_gate:resid"), + ("constraints", 1.0, None, "constraint_unconfirmed:rvp"), + ("guard", 1.0, None, "constraint_unconfirmed:rvp"), +]) +def test_resume_retains_upstream_production_blocks(tmp_path, stage, resid, limit, block): + task = _task(tmp_path, resid=resid, limit=limit) + first, cycle = _interrupt_after_stage(task, stage) + + resumed = run_cycle(task, no_agent=True) + + assert resumed["cycle_id"] == first["cycle_id"] + guard = json.loads((cycle / "guard.json").read_text()) + assert guard["accepted"] == [] + assert block in guard["blocks"] + assert Ledger(pathlib.Path(task) / "continuous" / "ledger" / "events.jsonl").events() == [] + + +def test_resume_restores_frozen_user_restrictions_before_revalidation(tmp_path): + from neqsim_continuous import user_input + + task = _task(tmp_path) + entry = user_input.add(task, "Tighter current RVP limit", by="engineer", + effects=[{"kind": "constraint", "name": "rvp", "limit": 0.5}]) + first, cycle = _interrupt_after_stage(task, "inputs") + # A later edit belongs to the next cycle, not the already frozen input stage. + user_input.resolve(task, entry["id"], by="engineer") + + resumed = run_cycle(task, no_agent=True) + + assert resumed["cycle_id"] == first["cycle_id"] + assert resumed["degraded"] is False + guard = json.loads((cycle / "guard.json").read_text()) + assert guard["accepted"] == [] + assert Ledger(pathlib.Path(task) / "continuous" / "ledger" / "events.jsonl").events() == [] + assert entry["id"] in (cycle / "digest.md").read_text() + + +def test_resume_missing_user_input_checkpoint_blocks_advice(tmp_path): + task = _task(tmp_path) + first, cycle = _interrupt_after_stage(task, "inputs") + (cycle / "user_input.json").unlink() + + resumed = run_cycle(task, no_agent=True) + + assert resumed["cycle_id"] == first["cycle_id"] + assert resumed["degraded"] is True + guard = json.loads((cycle / "guard.json").read_text()) + assert guard["accepted"] == [] + assert "user_input_resume_state_invalid" in guard["blocks"] + assert Ledger(pathlib.Path(task) / "continuous" / "ledger" / "events.jsonl").events() == [] + + +def test_optimizer_evidence_cannot_reach_ledger_without_guard(tmp_path): + task = _task(tmp_path) + plan_path = pathlib.Path(task) / "continuous" / "cycle_plan.yaml" + plan = yaml.safe_load(plan_path.read_text()) + plan["stages"].remove("guard") + plan_path.write_text(yaml.safe_dump(plan)) + + manifest = run_cycle(task, no_agent=True) + + statuses = {row["name"]: row["status"] for row in manifest["stages"]} + assert statuses["ledger"] == "fail" + assert manifest["degraded"] is True + assert Ledger(pathlib.Path(task) / "continuous" / "ledger" / "events.jsonl").events() == [] + + +@pytest.mark.parametrize("change", ["old_cycle", "setpoint", "evidence_boolean", "new_proposal", "removed_evidence"]) +def test_ledger_rejects_proposals_added_or_changed_after_guard(tmp_path, change): + from neqsim_continuous import stages + + ctx = _guard_context(tmp_path, _valid_proposal()) + ctx.state_dir, ctx.dry_run, ctx.evidence_analysis = str(tmp_path), False, None + ctx.kpis = {} + ctx.stage_results = {"guard": production.guard(ctx, {})} + accepted = copy.deepcopy(ctx.proposals[0]) + if change == "old_cycle": + ctx.cycle_id = "C-2" + ctx.proposals = [] + ctx.stage_results["guard"] = production.guard(ctx, {}) + ctx.proposals.append(accepted) + elif change == "setpoint": + ctx.proposals[0]["setpoints"]["p3_barg"] = 99.0 + elif change == "evidence_boolean": + # Python equality treats True and 1 as equal, but the evidence contract does not. + ctx.proposals[0]["optimizer_evidence"]["simulation_converged"] = 1 + elif change == "removed_evidence": + del ctx.proposals[0]["optimizer_evidence"] + del ctx.proposals[0]["accepted_point_envelope_evidence"] + else: + accepted["title"] = "unchecked extra proposal" + ctx.proposals.append(accepted) + + result = stages.ledger(ctx, {}) + + assert result.status == "fail" + assert "unguarded_optimizer_proposal" in result.triggers + assert Ledger(tmp_path / "ledger" / "events.jsonl").events() == [] + + def test_resume_after_ledger_keeps_current_cycle_revalidation_current(tmp_path): task = _task(tmp_path) run_cycle(task, no_agent=True) diff --git a/docs/development/CONTINUOUS_TASK_SOLVING.md b/docs/development/CONTINUOUS_TASK_SOLVING.md index bc3fb92d9e6..f714a28b81d 100644 --- a/docs/development/CONTINUOUS_TASK_SOLVING.md +++ b/docs/development/CONTINUOUS_TASK_SOLVING.md @@ -477,7 +477,11 @@ rewritten. The persisted identity is task-relative and records writer provenance than checkout-absolute paths, so moving a task between machines does not invalidate it. If a process stops after a cycle begins, `task-resume` reuses the same cycle id and skips -already completed stages even when the wall clock or host changes. Completed digest, +already completed calculation stages even when the wall clock or host changes. Production +gates, constraints and proposal validation run again against the restored values before +advice can be published; a saved successful stage status does not restore its filtering. +The input stage reapplies its frozen restrictions without repeating input side effects. +An unavailable input checkpoint blocks advice. Completed digest, notification and agent-review metadata are restored when such stages are skipped, so a resumed cycle cannot erase already recorded side effects. If a solve cycle completed but the process died before `state.json` was checkpointed, the solve loop adopts that completed @@ -557,7 +561,7 @@ neqsim task-solve --reset # forget earlier solve rounds Each cycle prints its digest. The cycle id is the UTC time and host (`2026-09-30T0500Z@myhost`); a rerun in the same minute gets `-r2`. A crashed -cycle is resumed by running it again: completed stages are skipped. +cycle is resumed by running it again: completed calculations are reused and production validation is rerun. --- @@ -1017,7 +1021,12 @@ optimizer evidence. The Living Report exposes the evidence state for every ledge A malformed optimizer result or failed guard still leaves the old advice visibly stale, even when the guarded ledger-publication stage is skipped. Retrying or resuming the same invalidation does not duplicate its events; -resuming after a replacement was already persisted preserves that cycle's fresh evidence. A dry run reports the +resuming after a replacement was already persisted preserves that cycle's fresh evidence. Resume restores the +original optimizer proposals and reruns the inexpensive gates, constraints and guard; withheld proposals remain +withheld, and accepted proposals retain their approval requirement. It does not rerun the completed model or +optimizer. The ledger also requires optimizer/envelope proposals to match the exact content accepted by the +current cycle's guard. This rejects custom plans that omit the guard, proposals inserted after the guard, +and changes to already guarded proposals. A dry run reports the opportunities it would invalidate without changing the persisted ledger. The guard remains required before any new proposal can be written. Custom production plans must keep `evidence` before model update, optimization, and `guard`, as the production diff --git a/docs/development/TASK_LOG.md b/docs/development/TASK_LOG.md index 765e2e771c9..e5c55c7a20e 100644 --- a/docs/development/TASK_LOG.md +++ b/docs/development/TASK_LOG.md @@ -36,6 +36,12 @@ requirement`, or `confidential compressor route`. +### 2026-10-14 — Preserve production proposal guards across interrupted cycles +**Type:** G (Workflow) +**Keywords:** PR 4312, resume, proposal guard, evidence ledger, frozen user input, fail closed +**Solution:** `devtools/neqsim_continuous/cycle.py`, `user_input.py`, `stages.py`, and `devtools/test_neqsim_production.py`. +**Notes:** A completed guard status did not preserve its filtered proposals or upstream blocks: resuming before ledger publication could mark previously withheld advice as current evidence. Resume now revalidates the inexpensive gates, constraints and guard against restored calculation outputs, reapplies frozen user restrictions, and blocks advice if the input checkpoint is unavailable. Completed model/optimizer work and persisted ledger events remain checkpointed. The ledger binds publication to the exact current-cycle guarded proposal content, rejecting post-guard mutations, added proposals and reused old-cycle evidence. Thirteen deterministic regressions failed on the previous head and pass with the repair; the full production suite passes 57 cases. Continuous/living coverage passes 42 cases, with one existing optional DOCX-report test skipped because its dependency is unavailable. Hosted CI remains a publication gate. + ### 2026-10-14 — Isolate evidence routing tests from optional agent installations **Type:** G (Workflow) **Keywords:** evidence-source-cues, hermetic tests, plugin discovery, production guard, CI, PR 4312