diff --git a/.github/skills/neqsim-continuous-task-improvement/SKILL.md b/.github/skills/neqsim-continuous-task-improvement/SKILL.md index 4d92391e17c..99c6337f748 100644 --- a/.github/skills/neqsim-continuous-task-improvement/SKILL.md +++ b/.github/skills/neqsim-continuous-task-improvement/SKILL.md @@ -198,6 +198,25 @@ 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. + Map production/injection route line-ups, controlled reservoir-lifecycle boundaries, mapped rotating-equipment + power, common-shaft speed/power, driver limits, shared host-power limits, and other operating-limit files to + `guard` with dependencies on the model update, optimizer, gates, and constraints. Keep route selectors as process + actions, reservoir pressure as a model input owned by `WellFlow.ReservoirLayer`, and mapped equipment power as + solved evidence read from the canonical `ProcessModel.getPower(...)` path. On a detected relevant revision + the append-only ledger marks old validation evidence non-current + and supersedes unfinished advice during the evidence stage, before model/optimizer/guard work can fail. A failed + guard still blocks publication of new advice, but cannot leave the old item marked current. Retry and resume + preserve idempotent invalidation and the current cycle's already-persisted replacement; dry runs do not mutate the + ledger. Custom production plans must keep `evidence` before model update, optimization, and `guard` to retain this + failure-path guarantee. Only a newly guarded point becomes a separate + `proposed` item with `revalidation_of`; if no safe replacement exists, expose none and never retain stale advice. + When a later revision is safe, resume lineage from the most recent superseded item rather than silently starting + a disconnected incumbent. - **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/cycle.py b/devtools/neqsim_continuous/cycle.py index 7b8cf876b29..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 @@ -24,8 +25,12 @@ SCHEMA_VERSION = "1.0" NEEDS = {"drift": ["kpis"], "diff": ["kpis"], "goal": ["kpis"], "notify": ["digest"], - "gates": ["kpis"], "constraints": ["kpis"], "guard": ["gates", "constraints"], "outcome": ["kpis"]} + "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): @@ -58,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 @@ -229,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")}) @@ -237,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/ledger.py b/devtools/neqsim_continuous/ledger.py index bd45d3f1e76..b02f60a9e3c 100644 --- a/devtools/neqsim_continuous/ledger.py +++ b/devtools/neqsim_continuous/ledger.py @@ -62,6 +62,10 @@ def current(self): item["status"] = event["to"] elif kind == "value_updated": item["value"] = event["value"] + elif kind == "evidence_invalidated": + item["evidence_current"] = False + item["evidence_invalidated_cycle"] = event.get("cycle") + item["evidence_invalidated_by"] = list(event.get("changes") or []) item["history"].append({k: v for k, v in event.items() if k != "id"}) return items @@ -102,6 +106,16 @@ def update_value(self, key, value, cycle=""): raise LedgerError("Unknown opportunity '{}'".format(key)) return self._append({"event": "value_updated", "id": key, "value": value, "cycle": cycle}) + def invalidate_evidence(self, key, changes, cycle=""): + """Mark persisted validation evidence stale without rewriting its history.""" + if key not in self.current(): + raise LedgerError("Unknown opportunity '{}'".format(key)) + compact = [{name: row.get(name) for name in + ("path", "change", "before_sha256", "after_sha256", "impact_rules") + if row.get(name) is not None} for row in changes or []] + return self._append({"event": "evidence_invalidated", "id": key, + "cycle": cycle, "changes": compact}) + def merge(self, other_path): """Union this ledger with another copy; returns the number of new events.""" mine = self.events() diff --git a/devtools/neqsim_continuous/living.py b/devtools/neqsim_continuous/living.py index 54efbad644f..3ef82455e65 100644 --- a/devtools/neqsim_continuous/living.py +++ b/devtools/neqsim_continuous/living.py @@ -72,16 +72,32 @@ 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 + evidence_sensitive_stages: ["script:model_update", "script:optimize", gates, constraints, guard] # 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} triggers: kpi_step: {} criteria: {} -evidence: {rules: [], dependencies: {}} +evidence: + rules: [] +# - name: field-operating-definition +# matches: [study_config.yaml, continuous/goal.yaml] +# stages: [guard] +# conclusions: [validated field operating point and envelope] + dependencies: + guard: ["script:optimize", gates, constraints] + "script:optimize": ["script:model_update"] + gates: ["script:model_update"] + constraints: ["script:model_update"] stages: [sense, evidence, inputs, refresh, "script:model_update", "script:optimize", kpis, gates, constraints, guard, drift, goal, diff, outcome, ledger, digest, notify, agent] notify: diff --git a/devtools/neqsim_continuous/living_report.py b/devtools/neqsim_continuous/living_report.py index 642fbda634c..dff7668bdee 100644 --- a/devtools/neqsim_continuous/living_report.py +++ b/devtools/neqsim_continuous/living_report.py @@ -278,11 +278,18 @@ def build(task_dir): for key, item in sorted(items.items()): decided = [h for h in item.get("history", []) if h.get("event") == "status_changed"] value = item.get("value") + evidence = "-" + if item.get("evidence_current") is True: + evidence = "current" + elif item.get("evidence_current") is False: + evidence = "stale since {}".format(item.get("evidence_invalidated_cycle") or "unknown cycle") rows.append((key, item.get("status"), item.get("category"), item.get("title"), value.get("value") if isinstance(value, dict) else value, + evidence, "{} {}".format(decided[-1].get("by"), str(decided[-1].get("at", ""))[:10]) if decided else "-")) - out += _table(["Id", "Status", "Category", "Title", "Latest value", "Last decision"], rows) + out += _table(["Id", "Status", "Category", "Title", "Latest value", "Evidence", + "Last decision"], rows) waiting = [k for k, v in items.items() if v.get("status") == "proposed"] if waiting: out += ["", "Waiting for a decision: " + ", ".join(sorted(waiting)) + diff --git a/devtools/neqsim_continuous/production.py b/devtools/neqsim_continuous/production.py index 231817a916d..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 @@ -193,6 +194,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): @@ -200,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) @@ -234,6 +370,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"])) @@ -246,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 817d5cb2ab3..894d617077f 100644 --- a/devtools/neqsim_continuous/stages.py +++ b/devtools/neqsim_continuous/stages.py @@ -15,7 +15,7 @@ from . import agent_launch, notify from .contracts import StageResult, register, resolve from .drift import DriftMonitor -from .ledger import Ledger +from .ledger import TRANSITIONS, Ledger from .plan import file_sha256, read_json, write_json from .stop_rules import should_reopen from .watermarks import Watermarks, parse_time @@ -93,6 +93,7 @@ def restore_evidence(ctx): report = read_json(os.path.join(ctx.cycle_dir, "evidence_changes.json"), {}) or {} report["inventory"] = read_json(os.path.join(ctx.cycle_dir, "evidence_inventory.json"), {}) or {} ctx.evidence_analysis = report + _invalidate_changed_evidence(ctx) _describe_evidence(ctx, report) @@ -102,6 +103,8 @@ def evidence(ctx, spec): report = ctx.evidence_analysis or analyze(ctx.task_dir, ctx.plan) ctx.evidence_analysis = report + # Retire the incumbent before model/optimizer/guard execution can fail. + _invalidate_changed_evidence(ctx) relative = "evidence_changes.json" inventory_file = "evidence_inventory.json" write_json(os.path.join(ctx.cycle_dir, relative), { @@ -308,21 +311,104 @@ def diff(ctx, spec): return StageResult("diff", "ok", outputs=["diff_vs_baseline.json"], triggers=triggers) +def _invalidate_changed_evidence(ctx): + """Invalidate old advice independently of proposal validation, including on resume. + + Repeated calls are idempotent. If interruption followed the invalidation event + but preceded supersession, finish that transition before running new work. + """ + book = Ledger(os.path.join(ctx.state_dir, "ledger", "events.jsonl")) + impact = ctx.evidence_analysis or {} + production = ctx.plan.get("production", {}) or {} + default_sensitive = ["script:model_update", "script:optimize", "gates", "constraints", "guard"] + configured_sensitive = production.get("evidence_sensitive_stages") + valid_sensitive = configured_sensitive is None or ( + isinstance(configured_sensitive, list) and configured_sensitive + and all(isinstance(name, str) and name for name in configured_sensitive)) + sensitive = set(default_sensitive if configured_sensitive is None else configured_sensitive) \ + if valid_sensitive else set() + invalidates = bool(impact.get("changes")) and ( + not valid_sensitive or impact.get("full_rerun") + or sensitive.intersection(impact.get("affected_stages") or [])) + invalidated = list(impact.get("invalidated_opportunities") or []) + if invalidates: + changes = impact.get("changes") or [] + for key, item in book.current().items(): + if item.get("status") in ("rejected", "superseded", "verified"): + continue + if item.get("cycle") == ctx.cycle_id: + # Resuming after ledger publication must retain this cycle's fresh evidence. + continue + if not item.get("optimizer_evidence") and not item.get("accepted_point_envelope_evidence"): + continue + if key not in invalidated: + invalidated.append(key) + if not ctx.dry_run: + if item.get("evidence_current") is not False: + book.invalidate_evidence(key, changes, cycle=ctx.cycle_id) + if "superseded" in TRANSITIONS.get(item.get("status"), ()): + book.set_status(key, "superseded", by="task-living", + note="validation evidence changed; fresh review required") + if ctx.evidence_analysis is not None: + ctx.evidence_analysis["invalidated_opportunities"] = invalidated + return invalidates, invalidated + + 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_titles = {v.get("title") for v in items.values() - if v.get("status") not in ("rejected", "superseded", "verified")} + open_items = {key: value for key, value in items.items() + if value.get("status") not in ("rejected", "superseded", "verified")} + open_by_title = {value.get("title"): (key, value) for key, value in open_items.items()} + latest_by_title = {} + for key, value in items.items(): + latest_by_title[value.get("title")] = (key, value) + impact = ctx.evidence_analysis or {} + created, updated = [], [] for proposal in ctx.proposals: - if proposal.get("title") in open_titles: + title = proposal.get("title") + previous = open_by_title.get(title) + if previous and not invalidates: continue + lineage = previous + if lineage is None and invalidates: + candidate = latest_by_title.get(title) + if (candidate and candidate[1].get("status") == "superseded" + and candidate[1].get("evidence_current") is False): + lineage = candidate created.append(proposal.get("title")) if not ctx.dry_run: fields = {k: v for k, v in proposal.items() if k not in ("title", "category")} + if proposal.get("optimizer_evidence") or proposal.get("accepted_point_envelope_evidence"): + fields["evidence_current"] = True + if lineage: + fields["revalidation_of"] = lineage[0] + fields["revalidation_changes"] = [ + {name: row.get(name) for name in ("path", "change", "impact_rules") + if row.get(name) is not None} for row in impact.get("changes") or []] book.create(proposal["title"], proposal.get("category", "operational"), cycle=ctx.cycle_id, **fields) for key, item in items.items(): + if key in invalidated: + continue kpi = item.get("kpi") if kpi and kpi in ctx.kpis and item.get("status") not in ("rejected", "superseded"): value = {"metric": kpi, "value": ctx.kpis[kpi], "cycle": ctx.cycle_id} @@ -330,7 +416,8 @@ def ledger(ctx, spec): book.update_value(key, value, cycle=ctx.cycle_id) updated.append(key) ctx.new_proposals = created - return StageResult("ledger", "ok", message="{} new, {} updated".format(len(created), len(updated))) + return StageResult("ledger", "ok", message="{} new, {} updated, {} evidence-invalidated".format( + len(created), len(updated), len(invalidated))) def _fmt(value): 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_continuous.py b/devtools/test_neqsim_continuous.py index 62539b960de..cfae6aac6e1 100644 --- a/devtools/test_neqsim_continuous.py +++ b/devtools/test_neqsim_continuous.py @@ -82,6 +82,15 @@ def test_ledger_lifecycle_and_merge(tmp_path): ledger.update_value("OPP-0001", {"p50": 0.38}, cycle="c2") assert ledger.current()["OPP-0001"]["status"] == "accepted" assert ledger.current()["OPP-0001"]["value"] == {"p50": 0.38} + ledger.invalidate_evidence("OPP-0001", [{ + "path": "study_config.yaml", "change": "modified", + "before_sha256": "old", "after_sha256": "new", + "impact_rules": ["operating-definition"], + }], cycle="c3") + invalidated = ledger.current()["OPP-0001"] + assert invalidated["evidence_current"] is False + assert invalidated["evidence_invalidated_cycle"] == "c3" + assert invalidated["evidence_invalidated_by"][0]["path"] == "study_config.yaml" server = nc.Ledger(tmp_path / "server" / "events.jsonl") for event in ledger.events(): diff --git a/devtools/test_neqsim_production.py b/devtools/test_neqsim_production.py index b41d112146c..7b21d6ffc01 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,127 @@ def run(ctx): STAGE_OPTIMIZE = ''' def run(ctx): - base = {"title": "%s", "category": "operational", "setpoints": {"p3_barg": 0.5}, + import math + import os + import yaml + + config_path = os.path.join(ctx.task_dir, "study_config.yaml") + with open(config_path, encoding="utf-8") as config_file: + config = yaml.safe_load(config_file) or {} + production_route = float(config.get("production_route_configuration", 0.0)) + injection_route = float(config.get("injection_route_configuration", 0.0)) + reservoir_pressure_bara = float(config.get("reservoir_pressure_bara", 150.0)) + mapped_network_power_kw = float(config.get("mapped_network_power_kw", 120.0)) + common_shaft_power_kw = float(config.get("common_shaft_power_kw", 900.0)) + common_shaft_speed_rpm = float(config.get("common_shaft_speed_rpm", 9000.0)) + driver_power_limit_kw = float(config.get("driver_power_limit_kw", 1100.0)) + shared_plant_power_limit_kw = float(config.get("shared_plant_power_limit_kw", 1200.0)) + shared_plant_power_kw = mapped_network_power_kw + common_shaft_power_kw + host_power_values = [mapped_network_power_kw, common_shaft_power_kw, + common_shaft_speed_rpm, driver_power_limit_kw, + shared_plant_power_limit_kw, shared_plant_power_kw] + host_power_finite = all(math.isfinite(value) for value in host_power_values) + accepted_values = [0.5, production_route, injection_route] + optimizer_evidence = { + "cycle_id": ctx.cycle_id, + "simulation_converged": True, + "candidate_feasible": (host_power_finite + and production_route in (0.0, 1.0) + and injection_route in (0.0, 1.0, 2.0) + and reservoir_pressure_bara > 0.0 + and common_shaft_speed_rpm > 0.0 + and common_shaft_power_kw <= driver_power_limit_kw + and shared_plant_power_kw <= shared_plant_power_limit_kw), + "candidate_finite": host_power_finite, + "constraint_evidence_complete": True, + "state_restore_complete": True, + "accepted_point_replayed": True, + "actions_complete": True, + "evaluation_count": 12, + "runtime_seconds": 1.25, + "operating_definition": { + "production_route_configuration": production_route, + "injection_route_configuration": injection_route, + "reservoir_pressure_bara": reservoir_pressure_bara, + "mapped_network_power_kw": mapped_network_power_kw, + "common_shaft_power_kw": common_shaft_power_kw, + "common_shaft_speed_rpm": common_shaft_speed_rpm, + "driver_power_limit_kw": driver_power_limit_kw, + "shared_plant_power_kw": shared_plant_power_kw, + "shared_plant_power_limit_kw": shared_plant_power_limit_kw, + }, + } + point = { + "candidate_values": accepted_values, + "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", "configuration.field.selection", + "configuration.injector route.selection"], + "accepted_candidate_values": accepted_values, + "slice_count": 3, + "requested_point_count": 3, + "completed_point_count": 3, + "envelope_evaluation_count": 3, + "envelope_runtime_seconds": 0.3, + "total_evaluation_count": 15, + "slices": [ + { + "study_id": "field-envelope", + "candidate_evaluator_id": "field-actions", + "axis_action_ids": ["p3_barg", "configuration.field.selection"], + "anchor_values": accepted_values, + "requested_point_count": 1, + "completed_point_count": 1, + "evaluation_count": 1, + "runtime_seconds": 0.1, + "outcome": "COMPLETE", + "complete": True, + "points": [point], + }, + { + "study_id": "field-envelope", + "candidate_evaluator_id": "field-actions", + "axis_action_ids": ["p3_barg"], + "anchor_values": accepted_values, + "requested_point_count": 1, + "completed_point_count": 1, + "evaluation_count": 1, + "runtime_seconds": 0.1, + "outcome": "COMPLETE", + "complete": True, + "points": [point], + }, + { + "study_id": "field-envelope", + "candidate_evaluator_id": "field-actions", + "axis_action_ids": ["configuration.injector route.selection"], + "anchor_values": accepted_values, + "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, + "configuration.field.selection": production_route, + "configuration.injector route.selection": injection_route}, "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}), @@ -77,12 +184,36 @@ def run(ctx): def _task(tmp_path, resid=1.0, limit=0.70): task = tmp_path / "t" (task / "continuous" / "stages").mkdir(parents=True) + (task / "study_config.yaml").write_text( + "production_route_configuration: 0.0\n" + "injection_route_configuration: 0.0\n" + "reservoir_pressure_bara: 150.0\n" + "mapped_network_power_kw: 120.0\n" + "common_shaft_power_kw: 900.0\n" + "common_shaft_speed_rpm: 9000.0\n" + "driver_power_limit_kw: 1100.0\n" + "shared_plant_power_limit_kw: 1200.0\n", encoding="utf-8") make_living(str(task), template="production") cont = task / "continuous" (cont / "stages" / "model_update.py").write_text(STAGE_UPDATE.replace('ctx.plan["_resid"]', str(resid))) (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["evidence"] = { + "rules": [{ + "name": "field-operating-definition", + "matches": ["study_config.yaml", "continuous/goal.yaml"], + "stages": ["guard"], + "conclusions": ["validated field operating point and envelope"], + }], + "dependencies": { + "guard": ["script:optimize", "gates", "constraints"], + "script:optimize": ["script:model_update"], + "gates": ["script:model_update"], + "constraints": ["script:model_update"], + }, + } 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 +242,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 +262,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 +320,429 @@ 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", "configuration.field.selection", "configuration.injector route.selection"] + assert envelope["accepted_candidate_values"] == [0.5, 0.0, 0.0] + assert envelope["slice_count"] == 3 + assert envelope["total_evaluation_count"] == 15 + assert safe["evidence_current"] is True + + +def test_selective_field_route_power_and_limit_revisions_supersede_stale_advice(tmp_path): + from neqsim_continuous import cli + + task = _task(tmp_path) + first = run_cycle(task, no_agent=True) + ledger_path = os.path.join(task, "continuous", "ledger", "events.jsonl") + first_items = Ledger(ledger_path).current() + first_id, first_item = next(iter(first_items.items())) + assert first_item["accepted_point_envelope_evidence"]["accepted_candidate_values"] == [0.5, 0.0, 0.0] + + config_path = os.path.join(task, "study_config.yaml") + with open(config_path, encoding="utf-8") as config_file: + config = yaml.safe_load(config_file) + config["production_route_configuration"] = 1.0 + with open(config_path, "w", encoding="utf-8") as config_file: + yaml.safe_dump(config, config_file, sort_keys=False) + + assert cli.main(["update", task, "--no-agent"]) == 0 + items = Ledger(ledger_path).current() + current = [(key, item) for key, item in items.items() if item["status"] == "proposed"] + assert len(current) == 1 + second_id, second_item = current[0] + assert second_item["revalidation_of"] == first_id + assert second_item["evidence_current"] is True + assert second_item["cycle"] != first["cycle_id"] + assert second_item["accepted_point_envelope_evidence"]["cycle_id"] == second_item["cycle"] + assert second_item["accepted_point_envelope_evidence"]["accepted_candidate_values"] == [0.5, 1.0, 0.0] + assert items[first_id]["status"] == "superseded" + assert items[first_id]["evidence_current"] is False + + with open(config_path, encoding="utf-8") as config_file: + config = yaml.safe_load(config_file) + config["injection_route_configuration"] = 1.5 + config["reservoir_pressure_bara"] = 175.0 + with open(config_path, "w", encoding="utf-8") as config_file: + yaml.safe_dump(config, config_file, sort_keys=False) + + assert cli.main(["update", task, "--no-agent"]) == 0 + items = Ledger(ledger_path).current() + assert items[second_id]["status"] == "superseded" + assert items[second_id]["evidence_current"] is False + assert not [item for item in items.values() + if item["status"] == "proposed" and item.get("evidence_current")] + + config["injection_route_configuration"] = 2.0 + with open(config_path, "w", encoding="utf-8") as config_file: + yaml.safe_dump(config, config_file, sort_keys=False) + + assert cli.main(["update", task, "--no-agent"]) == 0 + items = Ledger(ledger_path).current() + current = [(key, item) for key, item in items.items() if item["status"] == "proposed"] + assert len(current) == 1 + third_id, third_item = current[0] + assert third_item["revalidation_of"] == second_id + assert third_item["accepted_point_envelope_evidence"]["accepted_candidate_values"] == [0.5, 1.0, 2.0] + operating_definition = third_item["optimizer_evidence"]["operating_definition"] + assert operating_definition["reservoir_pressure_bara"] == 175.0 + assert operating_definition["mapped_network_power_kw"] == 120.0 + assert operating_definition["common_shaft_power_kw"] == 900.0 + assert operating_definition["shared_plant_power_kw"] == 1020.0 + + config["mapped_network_power_kw"] = 400.0 + with open(config_path, "w", encoding="utf-8") as config_file: + yaml.safe_dump(config, config_file, sort_keys=False) + + assert cli.main(["update", task, "--no-agent"]) == 0 + items = Ledger(ledger_path).current() + assert items[third_id]["status"] == "superseded" + assert items[third_id]["evidence_current"] is False + assert not [item for item in items.values() + if item["status"] == "proposed" and item.get("evidence_current")] + + config["shared_plant_power_limit_kw"] = 1400.0 + with open(config_path, "w", encoding="utf-8") as config_file: + yaml.safe_dump(config, config_file, sort_keys=False) + + assert cli.main(["update", task, "--no-agent"]) == 0 + items = Ledger(ledger_path).current() + current = [(key, item) for key, item in items.items() if item["status"] == "proposed"] + assert len(current) == 1 + fourth_id, fourth_item = current[0] + assert fourth_item["revalidation_of"] == third_id + power_definition = fourth_item["optimizer_evidence"]["operating_definition"] + assert power_definition["mapped_network_power_kw"] == 400.0 + assert power_definition["shared_plant_power_kw"] == 1300.0 + assert power_definition["shared_plant_power_limit_kw"] == 1400.0 + + goal_path = os.path.join(task, "continuous", "goal.yaml") + with open(goal_path, encoding="utf-8") as goal_file: + goal = yaml.safe_load(goal_file) + goal["constraints"][0]["limit"] = 0.62 + with open(goal_path, "w", encoding="utf-8") as goal_file: + yaml.safe_dump(goal, goal_file, sort_keys=False) + + assert cli.main(["update", task, "--no-agent"]) == 0 + items = Ledger(ledger_path).current() + assert items[fourth_id]["status"] == "superseded" + assert items[fourth_id]["evidence_current"] is False + assert not [item for item in items.values() + if item["status"] == "proposed" and item.get("evidence_current")] + with open(os.path.join(task, "continuous", "LIVING_REPORT.md"), encoding="utf-8") as report_file: + report = report_file.read() + assert "| Evidence |" in report + assert "stale since" in report + + +def test_non_finite_mapped_network_power_invalidates_without_replacement(tmp_path): + from neqsim_continuous import cli + + task = _task(tmp_path) + run_cycle(task, no_agent=True) + ledger_path = os.path.join(task, "continuous", "ledger", "events.jsonl") + first_id = next(iter(Ledger(ledger_path).current())) + config_path = os.path.join(task, "study_config.yaml") + with open(config_path, encoding="utf-8") as config_file: + config = yaml.safe_load(config_file) + config["mapped_network_power_kw"] = float("nan") + with open(config_path, "w", encoding="utf-8") as config_file: + yaml.safe_dump(config, config_file, sort_keys=False) + + assert cli.main(["update", task, "--no-agent"]) == 0 + items = Ledger(ledger_path).current() + assert items[first_id]["status"] == "superseded" + assert items[first_id]["evidence_current"] is False + assert not [item for item in items.values() + if item["status"] == "proposed" and item.get("evidence_current")] + + +def test_malformed_evidence_sensitivity_configuration_invalidates_fail_closed(tmp_path): + from neqsim_continuous import cli + + task = _task(tmp_path) + run_cycle(task, no_agent=True) + ledger_path = os.path.join(task, "continuous", "ledger", "events.jsonl") + first_id = next(iter(Ledger(ledger_path).current())) + + plan_path = os.path.join(task, "continuous", "cycle_plan.yaml") + with open(plan_path, encoding="utf-8") as plan_file: + plan = yaml.safe_load(plan_file) + plan["production"]["evidence_sensitive_stages"] = "guard" + with open(plan_path, "w", encoding="utf-8") as plan_file: + yaml.safe_dump(plan, plan_file, sort_keys=False) + + config_path = os.path.join(task, "study_config.yaml") + with open(config_path, encoding="utf-8") as config_file: + config = yaml.safe_load(config_file) + config["production_route_configuration"] = 1.0 + with open(config_path, "w", encoding="utf-8") as config_file: + yaml.safe_dump(config, config_file, sort_keys=False) + + assert cli.main(["update", task, "--no-agent"]) == 0 + items = Ledger(ledger_path).current() + assert items[first_id]["status"] == "superseded" + assert items[first_id]["evidence_current"] is False + + +def test_failed_guard_prevents_unchecked_proposal_from_reaching_ledger(tmp_path): + task = _task(tmp_path) + optimize_path = os.path.join(task, "continuous", "stages", "optimize.py") + with open(optimize_path, "w", encoding="utf-8") as optimize_file: + optimize_file.write("def run(ctx):\n return {'proposals': [5]}\n") + + manifest = run_cycle(task, no_agent=True) + statuses = {row["name"]: row["status"] for row in manifest["stages"]} + assert statuses["guard"] == "fail" + assert statuses["ledger"] == "skipped" + ledger_path = os.path.join(task, "continuous", "ledger", "events.jsonl") + assert Ledger(ledger_path).events() == [] + + +@pytest.mark.parametrize("prior_status", ["proposed", "accepted", "implemented"]) +@pytest.mark.parametrize("failure", ["malformed_proposal", "optimizer_exception"]) +def test_changed_power_limit_invalidates_existing_advice_before_failed_guard( + tmp_path, prior_status, failure): + task = _task(tmp_path) + run_cycle(task, no_agent=True) + book = Ledger(os.path.join(task, "continuous", "ledger", "events.jsonl")) + incumbent_id = next(iter(book.current())) + if prior_status in ("accepted", "implemented"): + book.set_status(incumbent_id, "accepted", by="test-reviewer") + if prior_status == "implemented": + book.set_status(incumbent_id, "implemented", by="test-reviewer") + + config_path = pathlib.Path(task) / "study_config.yaml" + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + config["shared_plant_power_limit_kw"] = 1.0 + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + optimize_path = pathlib.Path(task) / "continuous" / "stages" / "optimize.py" + optimize_path.write_text( + "def run(ctx):\n return {'proposals': [5]}\n" if failure == "malformed_proposal" + else "def run(ctx):\n raise RuntimeError('synthetic optimizer failure')\n", + encoding="utf-8") + + failed_cycle = run_cycle(task, no_agent=True) + statuses = {row["name"]: row["status"] for row in failed_cycle["stages"]} + assert failed_cycle["degraded"] is True + assert statuses["evidence"] == "ok" + if failure == "malformed_proposal": + assert statuses["guard"] == "fail" + assert statuses["ledger"] == "skipped" + else: + assert statuses["script:optimize"] == "fail" + incumbent = book.current()[incumbent_id] + assert incumbent["evidence_current"] is False + assert incumbent["evidence_invalidated_cycle"] == failed_cycle["cycle_id"] + assert incumbent["status"] == ("implemented" if prior_status == "implemented" else "superseded") + assert len(book.current()) == 1 + report = (pathlib.Path(task) / "continuous" / "LIVING_REPORT.md").read_text(encoding="utf-8") + row = next(line for line in report.splitlines() if line.startswith("| " + incumbent_id + " |")) + assert "stale since " + failed_cycle["cycle_id"] in row + assert "| current |" not in row + assert "Waiting for a decision: " + incumbent_id not in report + + # Repeating a degraded cycle must not append duplicate invalidation events. + run_cycle(task, no_agent=True) + invalidations = [event for event in book.events() if event["event"] == "evidence_invalidated"] + assert len(invalidations) == 1 + + # A later safe definition creates fresh advice and preserves prior lineage. + config["shared_plant_power_limit_kw"] = 1200.0 + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + optimize_path.write_text(STAGE_OPTIMIZE, encoding="utf-8") + recovered_cycle = run_cycle(task, no_agent=True) + assert recovered_cycle["degraded"] is False + replacements = [item for item in book.current().values() if item.get("evidence_current")] + assert len(replacements) == 1 + assert replacements[0]["revalidation_of"] == incumbent_id + assert replacements[0]["cycle"] == recovered_cycle["cycle_id"] + + +def test_dry_run_does_not_invalidate_persisted_evidence(tmp_path): + task = _task(tmp_path) + run_cycle(task, no_agent=True) + book = Ledger(os.path.join(task, "continuous", "ledger", "events.jsonl")) + before = book.events() + config_path = pathlib.Path(task) / "study_config.yaml" + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + config["shared_plant_power_limit_kw"] = 1.0 + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + + preview = run_cycle(task, dry_run=True, no_agent=True) + + assert preview["dry_run"] is True + assert preview["evidence"]["invalidated_opportunities"] == [next(iter(book.current()))] + 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) + book = Ledger(os.path.join(task, "continuous", "ledger", "events.jsonl")) + incumbent_id = next(iter(book.current())) + config_path = pathlib.Path(task) / "study_config.yaml" + config = yaml.safe_load(config_path.read_text(encoding="utf-8")) + config["production_route_configuration"] = 1.0 + config_path.write_text(yaml.safe_dump(config), encoding="utf-8") + completed = run_cycle(task, no_agent=True) + events_before_resume = book.events() + replacement_id = next(key for key, item in book.current().items() if item.get("evidence_current")) + + # Recreate interruption after the ledger's persisted side effects, before digest. + 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) + ledger_index = next(index for index, row in enumerate(interrupted["stages"]) if row["name"] == "ledger") + interrupted["stages"] = interrupted["stages"][:ledger_index + 1] + cycle_path.write_text(json.dumps(interrupted), encoding="utf-8") + + resumed = run_cycle(task, no_agent=True) + + assert resumed["cycle_id"] == completed["cycle_id"] + assert resumed["degraded"] is False + assert book.events() == events_before_resume + assert book.current()[replacement_id]["evidence_current"] is True + assert book.current()[replacement_id]["revalidation_of"] == incumbent_id @pytest.mark.parametrize("field, reason", [ @@ -172,6 +766,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 +778,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..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. --- @@ -935,8 +939,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 +970,68 @@ 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. + +Map every file that defines routing, constraints, limits, or model inputs to the production `guard`, including +mapped network pump/compressor power, common-shaft speed and power, driver limits, and shared host-power limits. +Declare the production-stage dependencies that must rebuild its evidence: + +```yaml +evidence: + rules: + - name: field-operating-definition + matches: [study_config.yaml, continuous/goal.yaml] + stages: [guard] + conclusions: [validated field operating point and envelope] + dependencies: + guard: ["script:optimize", gates, constraints] + "script:optimize": ["script:model_update"] + gates: ["script:model_update"] + constraints: ["script:model_update"] +``` + +`neqsim task-update ` then reruns the model, optimizer, constraints, and guard while retaining unrelated +cycle results. Map production and injection line-ups plus controlled reservoir-lifecycle boundaries and host-power +definitions through the same rule. The route selectors remain discrete process actions, while reservoir pressure +remains a model input synchronized through its owning `WellFlow.ReservoirLayer`. Read mapped network and standalone +rotating-equipment demand from the canonical `ProcessModel.getPower(...)` result and preserve the common-shaft, +driver, and shared-resource evidence; the task layer does not duplicate well, network, or rotating-equipment physics. +Non-finite mapped demand or a driver/shared-power breach produces no replacement proposal. Before any +model update, optimization, or guard runs, the evidence stage marks prior optimizer/envelope evidence in the ledger +`evidence_current: false`. Unfinished advice is superseded and cannot remain the current incumbent. A replacement +is a new `proposed` ledger item with `revalidation_of`, the changed evidence paths, the new cycle identity, and a +fresh envelope; this deliberately requires a new engineering review. If the revised limit or route leaves no safe +candidate, no replacement is created. Historical `implemented` records are retained for outcome traceability but +their validation evidence is still marked non-current. If a later operating definition becomes safe again, its new +item links to the most recent superseded item even when an intermediate cycle produced no proposal. Changes mapped +only to reporting stages do not invalidate +optimizer evidence. The Living Report exposes the evidence state for every ledger item. Unmapped changes and malformed +`evidence_sensitive_stages` configuration retain fail-closed invalidation behavior. + +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. 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 +template does. The ledger fallback alone cannot provide early invalidation when a failed guard skips publication. + ### 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/development/TASK_LOG.md b/docs/development/TASK_LOG.md index f1934014642..4232898723d 100644 --- a/docs/development/TASK_LOG.md +++ b/docs/development/TASK_LOG.md @@ -54,6 +54,24 @@ requirement`, or `confidential compressor route`. **Solution:** `Compressor.SpeedSolveException`, `ProductionOptimizer` candidate rejection and hard convergence gate, and conditional chart-setup process runs in `FlowRateOptimizer`. **Notes:** Reproduced all three failing optimizer cases on PR #4326 before repair. A no-valid-map trial is rejected without reading stale objectives or downstream constraints; unrelated exceptions still propagate. Capacity analysis and optional output validation cannot bypass the pressure-target convergence requirement. Repeated table setup reuses existing charts without rerunning an invalid preceding probe. Updated compressor/optimization guides, the compressor skill and the generated coverage inventory; regression tests cover stale outputs, hard convergence, recovery and chart reuse. +### 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 +**Solution:** `devtools/test_evidence_sources.py` and `.github/workflows/ai-devtools-python-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. The coordinated master workflow now retains this shared repair. + +### 2026-10-14 — Invalidate field evidence before optimizer or guard failure +**Type:** B (Bug fix) +**Keywords:** PR 4312, persistent envelope, evidence invalidation, failed guard, power limit, resume +**Solution:** `devtools/neqsim_continuous/stages.py` and `devtools/test_neqsim_production.py`. +**Notes:** An existing proposal remained current after a changed shared-power limit and malformed optimizer output skipped the guarded ledger stage. Move invalidation to evidence assessment and restore, preserving the guard for new proposals. Repeated failures remain idempotent, implemented history remains stale and traceable, and resumed publication retains its fresh cycle evidence. Add failure/recovery, report, dry-run and resume regressions. Integrate current master workflow naming and shared CI repairs; use `ai-devtools-python-tests.yml` rather than restoring the removed workflow filename. + ### 2026-10-14 — Repair shared CI failures and preserve workflow diagnostics **Type:** G (Workflow) **Keywords:** PR 4315, hermetic tests, evidence-source-cues, slow shards, required checks, Surefire, CI diff --git a/docs/integration/EXTERNAL_OPTIMIZER_INTEGRATION.md b/docs/integration/EXTERNAL_OPTIMIZER_INTEGRATION.md index ceefb69c4e7..25eb2c9e08c 100644 --- a/docs/integration/EXTERNAL_OPTIMIZER_INTEGRATION.md +++ b/docs/integration/EXTERNAL_OPTIMIZER_INTEGRATION.md @@ -1094,6 +1094,33 @@ 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. +For a persistent field study, map production/injection route line-ups, controlled reservoir-lifecycle boundaries, +mapped network pump/compressor power, common-shaft speed/power, driver limits, shared host-power limits, and +`continuous/goal.yaml` limits to the living-task `guard`, with dependencies on the model update, optimizer, +gates, and constraints. Canonical route selectors remain discrete process actions; reservoir pressure remains a model +input synchronized by its owning `WellFlow.ReservoirLayer`. Mapped field-network and standalone rotating-equipment +power remains solved evidence from `ProcessModel.getPower(...)`, with the existing common-shaft and shared-resource +constraint evidence retained. Do not copy map or driver physics into the task layer. Non-finite power evidence or an +exceeded driver/shared-power limit fails closed without replacement advice. The evidence stage appends +an invalidation event for the old ledger item before model, optimizer, or guard execution. This remains effective +if optimizer output is malformed or the guard fails and the proposal-publication stage is skipped. Retried and +resumed invalidation is idempotent; dry runs do not change the ledger. Unfinished advice is superseded; a new item is proposed only +after the revised full-model point and envelope pass the same guard. The replacement carries `revalidation_of` and +the changed paths, so an earlier approval is never silently transferred to new setpoints or a new physical limit. +If re-optimization finds no safe point, the old evidence remains explicitly non-current and no replacement advice +is exposed. A later safe operating definition resumes the append-only lineage from that superseded item instead of +creating an untraceable incumbent. This reuses the standard evidence inventory and append-only ledger rather than +adding optimizer state. +In custom production plans, retain the template's ordering with `evidence` before model update, optimization, and +`guard` so invalidation happens even when later stages fail. + ### 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 37036214278..a47a259f4a0 100644 --- a/src/main/resources/neqsim/mcp/engineering-coverage.json +++ b/src/main/resources/neqsim/mcp/engineering-coverage.json @@ -4092,7 +4092,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..537cbefb6cb 100644 --- a/src/test/java/neqsim/process/util/optimizer/FieldToFacilityOptimizationAcceptanceTest.java +++ b/src/test/java/neqsim/process/util/optimizer/FieldToFacilityOptimizationAcceptanceTest.java @@ -30,6 +30,7 @@ import neqsim.process.equipment.network.FieldWellNetworkProcessUnit; import neqsim.process.equipment.network.LoopedPipeNetwork; import neqsim.process.equipment.network.NetworkDecisionVariable; +import neqsim.process.equipment.pump.Pump; import neqsim.process.equipment.reservoir.WellFlow; import neqsim.process.equipment.separator.Separator; import neqsim.process.equipment.stream.EnergyPort; @@ -51,7 +52,8 @@ import neqsim.thermo.system.SystemSrkEos; /** - * Synthetic end-to-end acceptance for live wells, reversible separator routing and host constraints. + * Synthetic end-to-end acceptance for live wells, reversible separator routing, mapped network power and host + * constraints. * * @author NeqSim Development Team * @version 1.0 @@ -61,6 +63,7 @@ class FieldToFacilityOptimizationAcceptanceTest { private static final class Fixture { private final ProcessModel model; private final FieldWellNetworkProcessUnit networkUnit; + private final LoopedPipeNetwork mappedInjectionPowerNetwork; private final Separator highPressureSeparator; private final Separator lowPressureSeparator; private final Separator highPressureScrubber; @@ -82,6 +85,7 @@ private static final class Fixture { * * @param model coupled process model * @param networkUnit live well/network unit + * @param mappedInjectionPowerNetwork curve-backed injection utility included in shared host power * @param highPressureSeparator high-pressure separator * @param lowPressureSeparator low-pressure separator * @param highPressureScrubber high-pressure compressor suction scrubber @@ -98,13 +102,15 @@ private static final class Fixture { * @param baselineRouteAHigh baseline producer-A high-pressure route flow in kg/hr * @param wellSuffixes deterministic well identities */ - private Fixture(ProcessModel model, FieldWellNetworkProcessUnit networkUnit, Separator highPressureSeparator, - Separator lowPressureSeparator, Separator highPressureScrubber, Separator lowPressureScrubber, - Compressor highPressureCompressor, Compressor lowPressureCompressor, Mixer exportMixer, - ThrottlingValve exportValve, MechanicalShaft shaft, EnergyPort driverPort, CompressorDriver driver, - Gearbox gearbox, double sharedPowerLimitKw, double baselineRouteAHigh, List wellSuffixes) { + private Fixture(ProcessModel model, FieldWellNetworkProcessUnit networkUnit, + LoopedPipeNetwork mappedInjectionPowerNetwork, Separator highPressureSeparator, Separator lowPressureSeparator, + Separator highPressureScrubber, Separator lowPressureScrubber, Compressor highPressureCompressor, + Compressor lowPressureCompressor, Mixer exportMixer, ThrottlingValve exportValve, MechanicalShaft shaft, + EnergyPort driverPort, CompressorDriver driver, Gearbox gearbox, double sharedPowerLimitKw, + double baselineRouteAHigh, List wellSuffixes) { this.model = model; this.networkUnit = networkUnit; + this.mappedInjectionPowerNetwork = mappedInjectionPowerNetwork; this.highPressureSeparator = highPressureSeparator; this.lowPressureSeparator = lowPressureSeparator; this.highPressureScrubber = highPressureScrubber; @@ -299,15 +305,28 @@ void evaluatesTenWellExportBoundaryWithCompleteConstraintEvidence() { assertTrue(routingSlice.isComplete(), routingSlice.getDiagnostics().toString()); assertTrue(routingSlice.getPoints().stream().allMatch( point -> point.getEvaluation().isBaselineRestored() && point.getEvaluation().isBaselineSimulationConverged())); + double nearbyChokeB = Math.max(actions.get(1).getLowerBound(), accepted[1] - 5.0); + SliceResult hostPowerSlice = acceptedPointStudy.evaluateOneDimensional(actions.get(1).getId(), + new double[] {nearbyChokeB, accepted[1]}, result); + assertTrue(hostPowerSlice.isComplete(), hostPowerSlice.getDiagnostics().toString()); + String sharedPowerId = sharedPowerDefinition().getQualifiedId(); + assertTrue(hostPowerSlice.getPoints().stream().allMatch( + point -> point.getEvaluation().isBaselineRestored() && point.getEvaluation().isBaselineSimulationConverged() + && point.getEvaluation().getPlantConstraintEvidence().stream() + .anyMatch(power -> sharedPowerId.equals(power.getQualifiedConstraintId()) + && power.hasAvailableEvidence() && Double.isFinite(power.getSample().getSampledValue())))); int evaluationsBeforeExport = simulation.getEvaluationCount(); Map envelopeEvidence = acceptedPointStudy.getAcceptedPointEnvelopeEvidence("synthetic-cycle-1", - result, acceptedSlice, routingSlice); + result, acceptedSlice, routingSlice, hostPowerSlice); assertEquals(evaluationsBeforeExport, simulation.getEvaluationCount()); - 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")); - assertEquals(Long.valueOf(4L), envelopeEvidence.get("envelope_evaluation_count")); - assertEquals(Long.valueOf(result.getEvaluationCount() + 4L), envelopeEvidence.get("total_evaluation_count")); + 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(3), envelopeEvidence.get("slice_count")); + assertEquals(Integer.valueOf(6), envelopeEvidence.get("requested_point_count")); + assertEquals(Integer.valueOf(6), envelopeEvidence.get("completed_point_count")); + assertEquals(Long.valueOf(6L), envelopeEvidence.get("envelope_evaluation_count")); + assertEquals(Long.valueOf(result.getEvaluationCount() + 6L), envelopeEvidence.get("total_evaluation_count")); assertTrue(Double.isFinite(((Double) envelopeEvidence.get("envelope_runtime_seconds")).doubleValue())); assertEquals(acceptedBottleneck.getQualifiedConstraintName(), ((Map) envelopeEvidence.get("accepted_point_bottleneck")).get("qualified_constraint_name")); @@ -403,6 +422,7 @@ private Fixture createFixture(int wellCount) { exportMixer.addStream(lowPressureCompressor.getOutletStream()); ThrottlingValve exportValve = new ThrottlingValve("gas export pressure control", exportMixer.getOutletStream()); exportValve.setOutletPressure(100.0, "bara"); + LoopedPipeNetwork mappedInjectionPowerNetwork = createMappedInjectionPowerNetwork(); ProcessSystem process = new ProcessSystem("field to facility"); process.add(networkUnit); @@ -414,10 +434,14 @@ private Fixture createFixture(int wellCount) { process.add(lowPressureCompressor); process.add(exportMixer); process.add(exportValve); + process.add(mappedInjectionPowerNetwork); ProcessModel model = new ProcessModel(); model.add("Field", process); model.run(); assertTrue(model.isModelConverged()); + assertTrue(mappedInjectionPowerNetwork.isConverged()); + assertTrue(Double.isFinite(mappedInjectionPowerNetwork.getPower("kW"))); + assertTrue(mappedInjectionPowerNetwork.getPower("kW") > 0.0); highPressureCompressor.generateCompressorChart("normal curves", 5); lowPressureCompressor.generateCompressorChart("normal curves", 5); highPressureCompressor.getCompressorChart().setUseCompressorChart(true); @@ -439,6 +463,9 @@ private Fixture createFixture(int wellCount) { EnergyPortMode.CALCULATED); driverPort.connect(shaft); double baselinePowerKw = model.getPower("kW"); + double expectedPowerKw = highPressureCompressor.getPower("kW") + lowPressureCompressor.getPower("kW") + + mappedInjectionPowerNetwork.getPower("kW"); + assertEquals(expectedPowerKw, baselinePowerKw, Math.max(1.0e-9, expectedPowerKw * 1.0e-10)); double driverLimitKw = baselinePowerKw * 1.8 / 0.98; CompressorDriver driver = new CompressorDriver(DriverType.ELECTRIC_MOTOR, driverLimitKw); driver.setMaxPower(driverLimitKw); @@ -467,9 +494,53 @@ private Fixture createFixture(int wellCount) { configureInstalledLimits(networkUnit, highPressureSeparator, lowPressureSeparator, highPressureScrubber, lowPressureScrubber, network, routeAHighOperatingLimit, wellSuffixes); - return new Fixture(model, networkUnit, highPressureSeparator, lowPressureSeparator, highPressureScrubber, - lowPressureScrubber, highPressureCompressor, lowPressureCompressor, exportMixer, exportValve, shaft, driverPort, - driver, gearbox, baselinePowerKw * 1.8, baselineRouteAHigh, wellSuffixes); + PlantSharedResourceEvidence baselinePowerEvidence = PlantSharedResourceEvidence.fromProcessModelShaftPower( + sharedPowerDefinition(), "mapped-power-baseline", baselinePowerKw * 1.8, model, model.isModelConverged(), + "synthetic mapped network and common-shaft power baseline"); + assertTrue(baselinePowerEvidence.isComplete(), baselinePowerEvidence.getDiagnostics().toString()); + assertEquals(baselinePowerKw, baselinePowerEvidence.getAggregateValue(), + Math.max(1.0e-9, baselinePowerKw * 1.0e-10)); + assertEquals(1, baselinePowerEvidence.getParticipants().size()); + assertEquals("Field", baselinePowerEvidence.getParticipants().get(0).getSourceId()); + assertEquals(baselinePowerKw, baselinePowerEvidence.getParticipants().get(0).getValue(), + Math.max(1.0e-9, baselinePowerKw * 1.0e-10)); + return new Fixture(model, networkUnit, mappedInjectionPowerNetwork, highPressureSeparator, lowPressureSeparator, + highPressureScrubber, lowPressureScrubber, highPressureCompressor, lowPressureCompressor, exportMixer, + exportValve, shaft, driverPort, driver, gearbox, baselinePowerKw * 1.8, baselineRouteAHigh, wellSuffixes); + } + + /** + * Build one independent curve-backed injection utility on the canonical field-network graph. + * + * @return convergable mapped pump network included in the host shared-power budget + */ + private LoopedPipeNetwork createMappedInjectionPowerNetwork() { + SystemInterface liquid = new SystemSrkEos(298.15, 5.0); + liquid.addComponent("nC10", 0.70); + liquid.addComponent("nC16", 0.30); + liquid.setMixingRule("classic"); + + FieldNetworkTopology topology = new FieldNetworkTopology("mapped injection utility"); + LoopedPipeNetwork network = topology.getHydraulicNetwork(); + network.setFluidTemplate(liquid); + network.setSolverType(LoopedPipeNetwork.SolverType.NEWTON_RAPHSON); + network.setMaxIterations(100); + network.setTolerance(500.0); + topology.addPressureSource("pump suction", "HOST-P401", NodeRole.INJECTION_SOURCE, Service.INJECTION, 5.0, 0.0, + 0.0); + topology.addFixedPressureSink("pump delivery", "P-401-D", NodeRole.HOST, Service.INJECTION, 12.0, 0.0); + + Pump pump = new Pump("mapped injection utility pump"); + pump.setSpeed(2500.0); + pump.getPumpChart().setCurves(new double[] {0.200, 298.15, 1.0, 1.0, 850.0}, new double[] {2000.0, 3000.0}, + new double[][] {{20.0, 40.0, 60.0}, {30.0, 60.0, 90.0}}, + new double[][] {{80.0, 70.0, 50.0}, {180.0, 158.0, 113.0}}, + new double[][] {{65.0, 75.0, 68.0}, {68.0, 80.0, 72.0}}); + pump.getPumpChart().setHeadUnit("meter"); + pump.getPumpChart().setUsePumpChart(true); + topology.addPumpWithCurve("mapped injection pump", "P-401", Service.INJECTION, FlowDirection.FROM_TO, + "pump suction", "suction", "pump delivery", "discharge", pump).setPumpSpeed(2500.0); + return network; } /** Add one live well node, header and production choke. */ @@ -653,6 +724,8 @@ private void assertBaselineRestored(Fixture fixture) { assertEquals(100.0, fixture.exportValve.getOutletStream().getPressure("bara"), 1.0e-6); assertTrue(fixture.model.isModelConverged()); assertTrue(fixture.networkUnit.getLastCouplingResult().isConverged()); + assertTrue(fixture.mappedInjectionPowerNetwork.isConverged()); + assertTrue(Double.isFinite(fixture.mappedInjectionPowerNetwork.getPower("kW"))); } /** Create a lean SRK gas for every live well and route. */ 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"));