Build a Guarded Power-Grid Agent Harness

Build a safe multi-agent harness that simulates and audits power-grid actions.

Introduction

30 Second Summary

Power-grid operators make decisions where one bad command can disconnect equipment or overload a busy line. A safe control system needs to show its reasoning before any command reaches the grid.

In this project, you will build a local power-grid agent harness in Python with Grid2Op. The finished harness previews every expert proposal before one selected action reaches the shared environment.

What You'll Build

You will demo a guarded sandbox that rejects a faulty expert before safely applying one traced proposal.

By the end of this project, you'll have:

  • A runnable 14-substation sandbox where you can inspect line loading, connection state, and cooldowns.
  • A proposal-based expert swarm where every agent shows its task, command, rationale, predicted reward, and predicted maximum line loading.
  • Guarded arbitration that rejects an out-of-range command before applying one selected action. Every decision is preserved in harness_log.jsonl for replay.
  • Secret Mission: Build an offline evaluator that ranks experts from the trace file.

Are there any prerequisites?

You should be comfortable with Python plus basic AI, reinforcement-learning, and power-system concepts. You need a Windows device with Visual Studio Code and Python 3.9 through 3.13. The project installs Grid2Op 1.12.5 locally at no cost.

Before We Start

Before any hands-on work, this is your moment to lock in the system you are building and the safety rule behind it. Specialized agents will submit proposals to one deterministic control boundary before anything can affect the shared Grid2Op environment.

Set Up the Windows Sandbox

A guarded harness needs a stable simulator environment. Global packages can change underneath your project or conflict with other Python tools.

This step isolates the project inside a virtual environment. It also pins Grid2Op to version 1.12.5 so every later safety experiment uses the same simulator code.

In this step, get ready to:
  • Create a local project workspace in Visual Studio Code.
  • Verify a supported Python version inside the workspace terminal.
  • Create an isolated environment with Grid2Op 1.12.5 installed.
Create the project workspace and verify Python

A VS Code workspace keeps your files in one location. Its integrated PowerShell terminal starts inside the folder you open.

  • Press the Windows key to open the search bar.
  • Type Visual Studio Code and press Enter.
  • Click File in the top menu bar.
  • Click Open Folder....
  • Select Desktop in the folder picker.
  • Click New Folder.
  • Type guarded-grid-harness as the folder name.
  • Click Select Folder.

You will see guarded-grid-harness at the top of the Explorer sidebar. VS Code now treats that folder as your project workspace.

  • Select Yes, I trust the authors if the Workspace Trust prompt appears.
  • Click View in the top menu bar.
  • Click Terminal.
  • Confirm the terminal profile label shows PowerShell.
  • Select PowerShell from the terminal profile dropdown if another shell opens.

The terminal starts inside guarded-grid-harness. Commands you run here create files inside the project.

Grid2Op currently supports Python versions 3.9 through 3.13. Checking now prevents an incompatible interpreter from breaking the package installation later.

  • Check the installed Python version by running this command:
py -3 --version

What does this check?

The py command uses the Windows Python launcher. The version line tells you which Python 3 interpreter the launcher currently selects.

  • Choose the tab that matches the result shown in your terminal.

✔️ I see a supported version

Good, your Python version falls between 3.9 and 3.13. Your interpreter is ready for the sandbox.

ⓧ I see an unsupported version

Your current interpreter sits outside Grid2Op's supported range. Install a supported release before creating the environment.

ⓧ Python is unavailable

The Windows Python launcher cannot find a Python 3 installation. Install a supported release before continuing.

Create and activate the virtual environment

The .venv folder gives this project its own Python packages. Installing Grid2Op there keeps your global Python installation untouched.

  • Create the .venv environment by running this command:
python -m venv .venv

What does this command do?

Python's venv module creates an isolated interpreter inside .venv. Packages installed after activation stay inside this project.

You will see a new .venv folder in the Explorer sidebar. That folder is the visible proof that the environment exists.

Missing the .venv folder?

  • Confirm the terminal is still inside the guarded-grid-harness folder.
  • Repeat the supported Python version check before creating the environment again.

Ask for help with creating the Windows virtual environment.

  • Activate the environment by running this command:
.\.venv\Scripts\Activate.ps1

What does activation change?

Activation points python and pip at the copies inside .venv. The environment name also appears at the start of your PowerShell prompt.

✔️ The environment is active

Your PowerShell prompt now starts with the environment name. Commands in this terminal use the isolated Python environment.

ⓧ PowerShell blocks the script

PowerShell can block local activation scripts through its execution policy. The documented current-user setting allows locally created scripts without changing the policy for every user.

  • Allow local scripts for your Windows user by running this command:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser

Why use the current-user scope?

The CurrentUser scope limits the policy change to your Windows account. RemoteSigned permits local scripts while retaining a signature requirement for downloaded scripts.

  • Retry the environment activation by running:
.\.venv\Scripts\Activate.ps1

What should change now?

The activation script updates the current PowerShell session. You will see the environment name at the start of the prompt when it succeeds.

  • Confirm the environment name now appears at the start of the PowerShell prompt.

Activation still blocked?

  • Confirm you ran the policy command in PowerShell under your own Windows account.
  • Close the current terminal using the trashcan icon.
  • Create a fresh PowerShell terminal from the Terminal menu.

Ask for help with a blocked PowerShell activation script.

  • Press Ctrl+Shift+P to open the Command Palette.

You will see the Command Palette at the top of the VS Code window.

  • Type Python: Select Interpreter into the Command Palette.
  • Select the interpreter whose path ends with .venv.

VS Code now uses the same isolated interpreter as your active PowerShell terminal. Future Python files in this workspace inherit that environment.

Pin and install Grid2Op

A requirements.txt file records the exact dependency version for the project. Anyone rebuilding the sandbox can install the same Grid2Op release.

  • Click New File... in the Explorer sidebar.
  • Type requirements.txt and press Enter.

You will see requirements.txt in the Explorer sidebar. The empty file opens in the editor.

  • Set the exact Grid2Op dependency by pasting this line into requirements.txt:
grid2op==1.12.5

What does this dependency pin do?

  • The grid2op name identifies the simulator package.
  • The ==1.12.5 constraint locks the installation to the project version.
  • Press Ctrl+S to save requirements.txt.

✔️ Awesome, I've got everything!

Your saved requirements file contains one exact dependency pin. You are ready to install it.

ⓧ I'd like to double check the full code

grid2op==1.12.5

What should match?

Your entire requirements.txt file should contain this single line. The version must be 1.12.5.

Before you install the dependency, which Grid2Op version do you expect pip to choose from the requirements file?

  • Install the pinned project dependency by running this command:
python -m pip install -r requirements.txt

What does this command do?

  • The python -m pip section runs the package installer from your active interpreter.
  • The -r option reads dependencies from requirements.txt.
  • The exact pin makes the installer choose Grid2Op 1.12.5.

The installation finishes without an error. That's your reproducible base in place: Grid2Op 1.12.5 now lives inside .venv.

Installation stopped?

  • Confirm the PowerShell prompt still shows the active virtual environment.
  • Repeat the supported Python version check if the installer reports a compatibility problem.
  • Compare the saved dependency line with the full-file reference above.

Ask for help with diagnosing the Grid2Op installation. Share the same terminal output in the NextWork community if you need another set of eyes.

Setup Checkpoint

  • Confirm the guarded-grid-harness folder is open in VS Code.
  • Confirm PowerShell reports a Python version from 3.9 through 3.13.
  • Confirm the .venv folder exists in the Explorer sidebar.
  • Confirm the PowerShell prompt shows the virtual environment as active.
  • Confirm the selected VS Code interpreter points to .venv.
  • Confirm requirements.txt contains exactly grid2op==1.12.5.
  • Confirm the installation output identifies Grid2Op 1.12.5 as installed.

Your Windows sandbox is isolated and reproducible. Next, you will run the packaged grid environment and inspect its first observable state.

Run the Grid and Observe Its State

Your isolated .venv now has Grid2Op version 1.12.5 installed. The selected interpreter is ready to load a grid.

Every expert later in the harness needs to inspect the same grid state. This step creates a repeatable environment loop that prints a compact loading and connectivity summary.

In this step, get ready to:
  • Start the packaged l2rpn_case14_sandbox environment.
  • Calculate the initial maximum rho value.
  • Count the connected lines from the same observation.
Start the packaged sandbox

The l2rpn_case14_sandbox environment uses a modified IEEE case-14 grid with 14 substations. The test=True setting uses environment data supplied with Grid2Op.

  • In the file sidebar of Visual Studio Code from earlier, use the new-file control beside the open project folder to create sandbox.py.
  • Paste the first environment loop into sandbox.py using this code:
from typing import Any

import grid2op

ENV_NAME = "l2rpn_case14_sandbox"


def main() -> None:
    with grid2op.make(ENV_NAME, test=True) as env:
        observation = env.reset(seed=7)
        print("Sandbox ready")
        print(f"Environment: {ENV_NAME}")


if __name__ == "__main__":
    main()

What Does This Code Do?

  • The Any import provides the observation annotation used by the summary helpers.
  • The ENV_NAME constant keeps the environment identifier in one place.
  • The grid2op.make() call opens the packaged test environment.
  • The env.reset(seed=7) call produces a repeatable initial observation.
  • Save sandbox.py.
  • Return to the activated PowerShell terminal from the previous step.
  • Start the packaged sandbox by running this command:
python sandbox.py

What Does This Run Check?

The selected Python interpreter runs sandbox.py inside the activated environment. A successful run proves that the installed package can create the packaged grid.

You should see Sandbox ready. The next line should show Environment: l2rpn_case14_sandbox.

Good progress: your installed Grid2Op package can now create the packaged sandbox.

Sandbox Not Starting?

  • Confirm that the terminal still uses the activated .venv from the previous step.
  • Check that sandbox.py is saved inside the open project folder.
  • Compare ENV_NAME with l2rpn_case14_sandbox.

Help me diagnose why my packaged Grid2Op sandbox does not start.

Summarize line loading

Each value in observation.rho compares one line's observed current flow with its thermal limit. The highest usable value gives the experts one compact measure of grid stress.

  • In sandbox.py, add this helper directly below the ENV_NAME constant:
def max_rho(observation: Any) -> float:
    values = [float(value) for value in observation.rho if float(value) == float(value)]
    return max(values) if values else 999.0

How Is Loading Summarized?

  • The list comprehension converts each usable rho value into a float.
  • The comparison filters out values that cannot be compared with themselves.
  • The max() call returns the highest remaining loading ratio.
  • The 999.0 fallback keeps an empty observation visibly unsafe.
  • In main(), find the line that prints the environment name.
  • Add the loading summary directly below it so the two lines match this result:
        print(f"Environment: {ENV_NAME}")
        print(f"Initial maximum rho: {max_rho(observation):.3f}")

What Does the New Output Show?

  • The first line confirms which environment produced the observation.
  • The second line passes that observation into max_rho().
  • The :.3f format displays the result with three decimal places.
  • Save sandbox.py.
  • Return to the activated PowerShell terminal from earlier.
  • Run the updated loading snapshot with this command:
python sandbox.py

What Changed in This Run?

The script reads the fixed observation created by env.reset(seed=7). It now prints the highest initial line-loading ratio.

You should now see a third output line beginning with Initial maximum rho:. The number after that label is the shared loading signal.

The grid now exposes one repeatable stress signal for every future expert.

Missing the Loading Value?

  • Confirm that max_rho() appears above main().
  • Check that the new print line calls max_rho(observation).
  • Match the indentation of the new print line with the environment print above it.

Help me fix the missing maximum rho output.

Inspect line connectivity

The observation.line_status array records whether each line is connected. Counting its true values turns the array into a compact topology check.

  • In sandbox.py, add this helper directly below max_rho().
def connected_line_count(observation: Any) -> int:
    return sum(1 for status in observation.line_status if bool(status))

How Is Connectivity Counted?

  • The connected_line_count() helper checks each entry in line_status.
  • The generator contributes one for every connected line.
  • The sum() call returns the connected-line count.
  • In main(), find the line that prints the initial maximum rho.
  • Add the connectivity summary directly below it so the two lines match this result:
        print(f"Initial maximum rho: {max_rho(observation):.3f}")
        print(f"Connected lines: {connected_line_count(observation)}/{int(env.n_line)}")

What Does the Final Line Add?

  • The connected_line_count(observation) call calculates how many lines are connected.
  • The env.n_line value supplies the environment's total line count.
  • The final output displays the connected count as a fraction of the total.
  • Save sandbox.py.
  • Return to the activated PowerShell terminal from earlier.

Before you run the complete snapshot, do you expect every line to be connected after the fixed reset?

  • Run the complete grid snapshot with this command:
python sandbox.py

What Does the Final Run Check?

This run reads both state signals from the observation created with seed 7. It confirms that the loading and connectivity helpers work together.

You should see Sandbox ready followed by Environment: l2rpn_case14_sandbox.

The third line should begin with Initial maximum rho:. The final line should begin with Connected lines:.

  • Read the number after Initial maximum rho: to inspect the grid's highest initial loading ratio.
  • Compare the connected-line count with the total shown after the slash.

That is the state boundary working: your sandbox now exposes the same repeatable snapshot to every future expert.

State Summary Incomplete?

  • Confirm that connected_line_count() reads observation.line_status.
  • Check that the final print line uses env.n_line as the total.
  • Match the indentation of every print statement inside the with block.

Help me troubleshoot my incomplete Grid2Op state summary.

✔️ Awesome, I've got everything!

Your saved sandbox.py now loads the packaged grid and prints its repeatable initial state.

ⓧ I'd like to double check the full code

from typing import Any

import grid2op

ENV_NAME = "l2rpn_case14_sandbox"


def max_rho(observation: Any) -> float:
    values = [float(value) for value in observation.rho if float(value) == float(value)]
    return max(values) if values else 999.0


def connected_line_count(observation: Any) -> int:
    return sum(1 for status in observation.line_status if bool(status))


def main() -> None:
    with grid2op.make(ENV_NAME, test=True) as env:
        observation = env.reset(seed=7)
        print("Sandbox ready")
        print(f"Environment: {ENV_NAME}")
        print(f"Initial maximum rho: {max_rho(observation):.3f}")
        print(f"Connected lines: {connected_line_count(observation)}/{int(env.n_line)}")


if __name__ == "__main__":
    main()

What Should Match?

Compare the imports and helper names with your saved file. Check the reset seed plus all four print statements.

Your grid now exposes a repeatable loading snapshot plus a connectivity snapshot. Next, you will give specialized agents that shared observation and watch a naive coordinator trust the wrong proposal.

Let Specialized Agents Propose Actions

Your Grid2Op sandbox already exposes line loading. It also shows which lines are connected.

A coordinator that trusts the first expert can send an invalid command toward the shared environment. In this step, you will expose that unsafe boundary with deterministic proposals.

In this step, get ready to:
  • Define a shared proposal contract for every expert.
  • Implement four deterministic experts that return proposed commands.
  • Pass the ordered proposals to a naive coordinator to expose its trust problem.
Define the proposal boundary

A Proposal is the shared envelope between an expert and the coordinator. Each expert fills the same four fields while the environment remains under coordinator control.

  • In sandbox.py, find the existing typing import.
  • Replace that import with the expanded imports below:
from dataclasses import dataclass
from typing import Any, Dict, List

What Do These Imports Support?

  • The data-class import supplies the structure used by every proposal.
  • The expanded typing import describes proposal commands plus ordered proposal collections.
  • Find the ENV_NAME constant in sandbox.py.
  • Add the proposal contract directly below that constant:
@dataclass
class Proposal:
    expert: str
    task: str
    command: Dict[str, Any]
    rationale: str

What Does This Contract Hold?

  • The expert field identifies the proposal source.
  • The task field states the expert's focused responsibility.
  • The command field holds structured action data for later inspection.
  • The rationale field records why the expert made its proposal.
  • Save sandbox.py.
  • Confirm the proposal contract parses by running:
python sandbox.py

What Should This Run Confirm?

You should see the existing sandbox summary with no traceback. This confirms the new contract loads without disrupting the environment loop.

Seeing a Syntax or Import Error?

  • Check that both import lines remain at the top of sandbox.py.
  • Check that every field inside Proposal has four spaces of indentation.

Help me fix the proposal contract in sandbox.py.

Add the four deterministic experts

Each expert inspects the same environment state from a narrow task perspective. It returns data for review without sending an action into the environment.

  • In sandbox.py, find the end of connected_line_count().
  • Add the fault-injection expert below that helper:
def fault_injection_expert(env: Any, observation: Any) -> Proposal:
    del observation
    return Proposal(
        expert="fault_injection",
        task="test the harness boundary",
        command={"set_line_status": [[int(env.n_line), -1]]},
        rationale="Submit an out-of-range line identifier to prove the coordinator does not trust experts.",
    )

How Does This Expert Test the Boundary?

  • The expert returns a complete Proposal instead of constructing an action.
  • Its command targets int(env.n_line). Valid zero-based line identifiers end before that value.
  • The -1 status requests a line disconnection.
  • Save sandbox.py.
  • Confirm the fault-injection expert parses by running:
python sandbox.py

What Does This Check Prove?

You should still see the sandbox summary with no traceback. The expert exists as a callable proposal source without running during import.

Fault Expert Not Parsing?

  • Check that the function sits outside connected_line_count() with no leading indentation.
  • Check that the four proposal fields remain inside the same Proposal call.

Help me fix the fault-injection expert definition.

  • Find the end of fault_injection_expert() in sandbox.py.
  • Add the conservative expert directly below it:
def conservative_expert(env: Any, observation: Any) -> Proposal:
    del env, observation
    return Proposal(
        expert="conservative",
        task="preserve the current topology",
        command={},
        rationale="Use the do-nothing action as a stable baseline.",
    )

Why Include a Conservative Expert?

The empty command represents a stable baseline that preserves the current topology. This gives later arbitration a low-intervention proposal to compare against topology changes.

  • Save sandbox.py.
  • Confirm the conservative expert parses by running:
python sandbox.py

What Should You See?

You should see the sandbox summary complete normally. The empty proposal command is stored as data at this stage.

Conservative Expert Causing an Error?

  • Check that command={} remains inside the returned proposal.
  • Check that del env, observation uses the same parameter names as the function signature.

Help me fix the conservative expert definition.

  • Find the end of conservative_expert() in sandbox.py.
  • Add the isolation expert directly below it:
def isolation_expert(env: Any, observation: Any) -> Proposal:
    eligible = [
        line_id
        for line_id in range(int(env.n_line))
        if bool(observation.line_status[line_id])
        and int(observation.time_before_cooldown_line[line_id]) == 0
    ]
    if not eligible:
        return Proposal(
            expert="isolation",
            task="isolate a highly loaded line",
            command={},
            rationale="No connected line is currently actionable, so hold position.",
        )

    line_id = max(eligible, key=lambda item: float(observation.rho[item]))
    return Proposal(
        expert="isolation",
        task="isolate a highly loaded line",
        command={"set_line_status": [[int(line_id), -1]]},
        rationale=f"Propose disconnecting actionable line {line_id}, which currently has the highest rho.",
    )

How Does Isolation Stay Deterministic?

  • The eligibility filter keeps connected lines whose cooldown is zero.
  • The selection chooses the eligible line with the highest current rho.
  • An empty command holds the current topology when no line qualifies.
  • Save sandbox.py.
  • Confirm the isolation expert parses by running:
python sandbox.py

What Does This Run Confirm?

You should see the sandbox summary with no traceback. The isolation logic can read line status plus cooldown data without changing the observation.

Isolation Expert Failing to Load?

  • Check the indentation inside the eligible list comprehension.
  • Check that both return paths produce a Proposal.
  • Check that observation.rho[item] uses item from the selection key.

Help me debug the isolation expert.

  • Find the end of isolation_expert() in sandbox.py.
  • Add the restoration expert directly below it:
def restoration_expert(env: Any, observation: Any) -> Proposal:
    eligible = [
        line_id
        for line_id in range(int(env.n_line))
        if not bool(observation.line_status[line_id])
        and int(observation.time_before_cooldown_line[line_id]) == 0
    ]
    if not eligible:
        return Proposal(
            expert="restoration",
            task="restore an available disconnected line",
            command={},
            rationale="No disconnected line is currently available for reconnection.",
        )

    line_id = eligible[0]
    return Proposal(
        expert="restoration",
        task="restore an available disconnected line",
        command={"set_line_status": [[int(line_id), 1]]},
        rationale=f"Propose reconnecting available line {line_id}.",
    )

How Does Restoration Choose a Line?

  • The eligibility filter keeps disconnected lines whose cooldown is zero.
  • The expert selects the first eligible line for repeatable behavior.
  • The 1 status requests reconnection.
  • Save sandbox.py.
  • Confirm all four expert definitions parse by running:
python sandbox.py

What Have You Confirmed?

You should see the normal sandbox summary with no traceback. All four experts now load as deterministic proposal functions.

Restoration Expert Failing to Load?

  • Check that the eligibility condition includes not bool(observation.line_status[line_id]).
  • Check that the reconnection command uses status 1.
  • Check that the function sits above main().

Help me debug the restoration expert.

Expose the naive coordinator

The first-proposal strategy treats list order as permission to act. Placing the fault-injection expert first reveals what happens when selection has no validation boundary.

  • Find the end of restoration_expert() in sandbox.py.
  • Add the naive coordinator directly below it:
def naive_coordinator(proposals: List[Proposal], env: Any) -> Any:
    chosen = proposals[0]
    print(f"Naive coordinator trusts: {chosen.expert}")
    return env.action_space(chosen.command)

Why Is This Coordinator Unsafe?

  • The coordinator selects index 0 without inspecting the proposal.
  • It passes the selected command directly into the action space.
  • List order becomes the only selection policy.
  • Select the existing main() function through its connected-lines print statement.
  • Replace the selected function with the version below:
def main() -> None:
    with grid2op.make(ENV_NAME, test=True) as env:
        observation = env.reset(seed=7)
        print("Sandbox ready")
        print(f"Environment: {ENV_NAME}")
        print(f"Initial maximum rho: {max_rho(observation):.3f}")
        print(
            f"Connected lines: {connected_line_count(observation)}/{int(env.n_line)}"
        )

        proposals = [
            fault_injection_expert(env, observation),
            conservative_expert(env, observation),
            isolation_expert(env, observation),
            restoration_expert(env, observation),
        ]

        try:
            naive_coordinator(proposals, env)
            print("UNEXPECTED: the naive coordinator accepted the malformed proposal")
        except Exception as exc:
            print(f"EXPECTED FAILURE: {type(exc).__name__}: {exc}")

What Does the Updated Loop Do?

  • The proposal list calls all four experts against the same observation.
  • The list places fault_injection first.
  • The exception handler keeps the lesson running after action construction rejects the selected command.

Before you run the sandbox, do you think list order is enough to keep an invalid proposal away from action construction?

  • Save sandbox.py.
  • Test the naive coordinator by running:
python sandbox.py

What Should You Observe?

The terminal prints Naive coordinator trusts: fault_injection. It then prints EXPECTED FAILURE followed by the action-construction exception details.

That is the intended shortfall. You have exposed the trust gap before an invalid action can modify the grid.

Not Seeing the Expected Failure?

  • Check that fault_injection_expert(env, observation) is the first item in proposals.
  • Check that naive_coordinator() selects proposals[0].
  • Check that the fault-injection command uses int(env.n_line) as its line identifier.

Help me reproduce the naive coordinator failure.

✔️ Awesome, I've got everything!

Your sandbox.py now defines four proposal experts. It also demonstrates the first-proposal coordinator's failure.

ⓧ I'd like to double check the full code

from dataclasses import dataclass
from typing import Any, Dict, List

import grid2op

ENV_NAME = "l2rpn_case14_sandbox"


@dataclass
class Proposal:
    expert: str
    task: str
    command: Dict[str, Any]
    rationale: str


def max_rho(observation: Any) -> float:
    values = [float(value) for value in observation.rho if float(value) == float(value)]
    return max(values) if values else 999.0


def connected_line_count(observation: Any) -> int:
    return sum(1 for status in observation.line_status if bool(status))


def fault_injection_expert(env: Any, observation: Any) -> Proposal:
    del observation
    return Proposal(
        expert="fault_injection",
        task="test the harness boundary",
        command={"set_line_status": [[int(env.n_line), -1]]},
        rationale="Submit an out-of-range line identifier to prove the coordinator does not trust experts.",
    )


def conservative_expert(env: Any, observation: Any) -> Proposal:
    del env, observation
    return Proposal(
        expert="conservative",
        task="preserve the current topology",
        command={},
        rationale="Use the do-nothing action as a stable baseline.",
    )


def isolation_expert(env: Any, observation: Any) -> Proposal:
    eligible = [
        line_id
        for line_id in range(int(env.n_line))
        if bool(observation.line_status[line_id])
        and int(observation.time_before_cooldown_line[line_id]) == 0
    ]
    if not eligible:
        return Proposal(
            expert="isolation",
            task="isolate a highly loaded line",
            command={},
            rationale="No connected line is currently actionable, so hold position.",
        )

    line_id = max(eligible, key=lambda item: float(observation.rho[item]))
    return Proposal(
        expert="isolation",
        task="isolate a highly loaded line",
        command={"set_line_status": [[int(line_id), -1]]},
        rationale=f"Propose disconnecting actionable line {line_id}, which currently has the highest rho.",
    )


def restoration_expert(env: Any, observation: Any) -> Proposal:
    eligible = [
        line_id
        for line_id in range(int(env.n_line))
        if not bool(observation.line_status[line_id])
        and int(observation.time_before_cooldown_line[line_id]) == 0
    ]
    if not eligible:
        return Proposal(
            expert="restoration",
            task="restore an available disconnected line",
            command={},
            rationale="No disconnected line is currently available for reconnection.",
        )

    line_id = eligible[0]
    return Proposal(
        expert="restoration",
        task="restore an available disconnected line",
        command={"set_line_status": [[int(line_id), 1]]},
        rationale=f"Propose reconnecting available line {line_id}.",
    )


def naive_coordinator(proposals: List[Proposal], env: Any) -> Any:
    chosen = proposals[0]
    print(f"Naive coordinator trusts: {chosen.expert}")
    return env.action_space(chosen.command)


def main() -> None:
    with grid2op.make(ENV_NAME, test=True) as env:
        observation = env.reset(seed=7)
        print("Sandbox ready")
        print(f"Environment: {ENV_NAME}")
        print(f"Initial maximum rho: {max_rho(observation):.3f}")
        print(
            f"Connected lines: {connected_line_count(observation)}/{int(env.n_line)}"
        )

        proposals = [
            fault_injection_expert(env, observation),
            conservative_expert(env, observation),
            isolation_expert(env, observation),
            restoration_expert(env, observation),
        ]

        try:
            naive_coordinator(proposals, env)
            print("UNEXPECTED: the naive coordinator accepted the malformed proposal")
        except Exception as exc:
            print(f"EXPECTED FAILURE: {type(exc).__name__}: {exc}")


if __name__ == "__main__":
    main()

How Should You Use This Reference?

Compare this cumulative file with your saved sandbox.py. The function names plus proposal order must match exactly.

Your experts now speak through one proposal contract. Next, you will add the control boundary that evaluates every proposal before one action reaches the environment.

Guard, Simulate, Select, and Trace

Your experts now share one Grid2Op environment through proposals. The naive coordinator already proved that trusting the first proposal can send an out-of-range command toward the simulator.

This step builds the control boundary that the coordinator was missing. It validates each proposal before action construction. It also records every decision in a replayable JSONL trace.

In this step, get ready to:
  • Validate untrusted proposals against the supported schema and current grid state.
  • Preview valid actions through simulation. Rank the safe survivors deterministically.
  • Apply one selected action. Record each decision in an audit trace.
Validate proposals before action construction

A proposal remains untrusted data until it passes the safety gate. The gate checks its structure before Grid2Op receives a command. It also compares the requested change with the live observation.

  • In sandbox.py, replace the import, constant, and data-class section at the top with the code below:
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import json

import grid2op

ENV_NAME = "l2rpn_case14_sandbox"
LOG_PATH = Path("harness_log.jsonl")
SAFETY_MARGIN = 0.05


@dataclass
class Proposal:
    expert: str
    task: str
    command: Dict[str, Any]
    rationale: str


@dataclass
class Evaluation:
    expert: str
    task: str
    accepted: bool
    reason: str
    predicted_max_rho: Optional[float]
    predicted_reward: Optional[float]
    predicted_done: Optional[bool]
    command: Dict[str, Any]

What does this foundation add?

  • The Evaluation data class stores the outcome of one safety review.
  • Optional prediction fields preserve rejected proposals that never reach simulation.
  • The SAFETY_MARGIN value allows up to 0.05 of additional maximum line loading before the harness rejects a topology change.
  • The LOG_PATH value gives every run one consistent audit-trace location.
  • Save sandbox.py.
  • Confirm that the existing sandbox still runs by executing this command:
python sandbox.py

What should you see?

You should still see Sandbox ready followed by EXPECTED FAILURE. This confirms that the new evaluation types preserve the deliberate naive-coordinator failure.

Seeing an import or name error?

  • Check that asdict appears beside dataclass in the first import.
  • Check that Optional and Tuple appear in the typing import.
  • Confirm that the existing Proposal class still appears before Evaluation.

Help me compare my import and data-class section with the required structure.

The first gate handles predictable mistakes without constructing a Grid2Op action. Its checks cover the command schema, line identifier, requested status, current connection state, and cooldown.

  • In sandbox.py, add the following function immediately below restoration_expert():
def validate_proposal(proposal: Proposal, env: Any, observation: Any) -> Tuple[bool, str]:
    if proposal.command == {}:
        return True, "valid do-nothing command"
    if set(proposal.command) != {"set_line_status"}:
        return False, "unsupported command schema"
    changes = proposal.command["set_line_status"]
    if not isinstance(changes, list) or len(changes) != 1:
        return False, "exactly one line-status change is allowed"
    change = changes[0]
    if not isinstance(change, (list, tuple)) or len(change) != 2:
        return False, "line-status change must contain line_id and status"
    line_id, status = change
    if isinstance(line_id, bool) or not isinstance(line_id, int):
        return False, "line_id must be an integer"
    if line_id < 0 or line_id >= int(env.n_line):
        return False, f"line_id {line_id} is outside 0..{int(env.n_line) - 1}"
    if status not in (-1, 1):
        return False, "status must be -1 or 1"
    if int(observation.time_before_cooldown_line[line_id]) != 0:
        return False, f"line {line_id} is under cooldown"
    if status == -1 and not bool(observation.line_status[line_id]):
        return False, f"line {line_id} is already disconnected"
    if status == 1 and bool(observation.line_status[line_id]):
        return False, f"line {line_id} is already connected"
    return True, "schema and grid-state checks passed"

How does the safety gate work?

  • An empty command passes as the stable do-nothing baseline.
  • The schema permits exactly one set_line_status change.
  • The range check rejects any identifier outside the environment's zero-based line range.
  • The state checks reject redundant changes or changes blocked by a line cooldown.
  • Save sandbox.py.
  • Confirm that the sandbox remains runnable by executing:
python sandbox.py

What does this run prove?

You should see the same sandbox summary and deliberate EXPECTED FAILURE output. The validator is now available without changing the existing naive path.

Seeing a syntax error in the validator?

  • Check that each rejection branch returns a Boolean followed by a reason string.
  • Confirm that the final success return remains inside validate_proposal().
  • Check the indentation beneath every if statement.

Help me find the syntax or indentation mistake in my proposal validator.

Preview and rank safe proposals

Structural validity only proves that a command has an allowed shape. The harness also needs to preview what that command does to the grid. Grid2Op provides a non-mutating simulation boundary for this check.

  • In sandbox.py, add the following function immediately below validate_proposal():
def evaluate_proposal(proposal: Proposal, env: Any, observation: Any) -> Evaluation:
    valid, reason = validate_proposal(proposal, env, observation)
    if not valid:
        return Evaluation(proposal.expert, proposal.task, False, reason, None, None, None, proposal.command)
    try:
        action = env.action_space(proposal.command)
        simulated_obs, simulated_reward, simulated_done, simulated_info = observation.simulate(action, time_step=0)
    except Exception as exc:
        return Evaluation(proposal.expert, proposal.task, False, f"simulation error: {type(exc).__name__}: {exc}", None, None, None, proposal.command)
    predicted_max_rho = max_rho(simulated_obs)
    illegal = bool(simulated_info["is_illegal"])
    ambiguous = bool(simulated_info["is_ambiguous"])
    if simulated_done:
        accepted, reason = False, "simulation reaches game over"
    elif illegal:
        accepted, reason = False, "simulation flags the action as illegal"
    elif ambiguous:
        accepted, reason = False, "simulation flags the action as ambiguous"
    elif proposal.command and predicted_max_rho > max_rho(observation) + SAFETY_MARGIN:
        accepted, reason = False, "predicted line loading degrades beyond the safety margin"
    else:
        accepted, reason = True, "proposal passed validation and simulation"
    return Evaluation(proposal.expert, proposal.task, accepted, reason, predicted_max_rho, float(simulated_reward), bool(simulated_done), proposal.command)

What does the evaluator reject?

  • The evaluator returns immediately when the structural gate rejects a proposal.
  • The simulate() call previews a valid action against the known current state.
  • The safety policy rejects game-over, illegal, or ambiguous previews.
  • A topology-changing proposal also fails when its predicted maximum rho exceeds the current value plus SAFETY_MARGIN.
  • Save sandbox.py.
  • Check the evaluator for syntax errors by running:
python sandbox.py

What should remain intact?

You should see Sandbox ready and the deliberate EXPECTED FAILURE message. This confirms that the evaluator loads successfully beside the existing coordinator.

Does the evaluator fail to load?

  • Confirm that evaluate_proposal() appears after validate_proposal().
  • Check that the simulation result is unpacked into four values.
  • Confirm that SAFETY_MARGIN uses the same capitalization as the constant near the top of the file.

Help me diagnose why my proposal evaluator does not load.

Several proposals can survive the safety checks. The coordinator resolves that case through a fixed tuple. Lower predicted loading wins first. Higher predicted reward wins the next comparison.

  • In sandbox.py, add the following function immediately below naive_coordinator():
def guarded_coordinator(evaluations: List[Evaluation]) -> Evaluation:
    accepted = [evaluation for evaluation in evaluations if evaluation.accepted]
    if not accepted:
        raise RuntimeError("The safety gate rejected every proposal.")
    return min(accepted, key=lambda item: (item.predicted_max_rho if item.predicted_max_rho is not None else 999.0, -(item.predicted_reward if item.predicted_reward is not None else -999.0), item.expert))

How is the winner selected?

  • Only accepted evaluations enter the ranking.
  • Lower predicted maximum line loading receives the strongest preference.
  • The negative reward term makes a higher predicted reward rank earlier.
  • The expert name provides a stable final tie-breaker.
  • Save sandbox.py.
  • Confirm that the coordinator definition loads by running:
python sandbox.py

What should the terminal confirm?

The terminal should still reach the deliberate naive failure without a new syntax error. Your script now has both coordinator paths available.

Seeing an error around the ranking key?

  • Check that min() receives the accepted list.
  • Confirm that the ranking tuple contains predicted loading, negative predicted reward, and expert name in that order.
  • Check that both optional prediction fields have fallback values.

Help me correct the deterministic ranking tuple in my guarded coordinator.

Apply one action and write the trace

The final boundary has two responsibilities. It records why every proposal passed or failed. It then converts only the selected proposal into the one action applied to the shared environment.

  • In sandbox.py, replace the start of main() with the following logging helper and reset logic:
def write_log(payload: Dict[str, Any]) -> None:
    with LOG_PATH.open("a", encoding="utf-8") as log_file:
        log_file.write(json.dumps(payload, sort_keys=True) + "\n")


def main() -> None:
    if LOG_PATH.exists():
        LOG_PATH.unlink()

Why reset the trace?

The writer stores one JSON object per line. Removing the previous trace at the start of main() keeps each run deterministic and easy to inspect.

  • Keep the existing environment block directly below the new reset logic.
  • Save sandbox.py.
  • Confirm that the reset logic preserves the existing run by executing:
python sandbox.py

What should this run preserve?

You should still see the sandbox summary and EXPECTED FAILURE. The script now clears an old trace before creating the environment.

Does main stop before the sandbox starts?

  • Confirm that write_log() sits above main().
  • Check that the existing Grid2Op context-manager line remains indented inside main().
  • Confirm that LOG_PATH.unlink() remains inside the existence check.

Help me fix the function placement or indentation around my trace reset.

The evaluation loop turns every proposal into an inspectable decision. Each printed outcome receives a matching trace record with the original command and all prediction fields.

  • In main(), add the following block immediately below the existing except block that prints EXPECTED FAILURE:
        evaluations = [evaluate_proposal(proposal, env, observation) for proposal in proposals]
        for evaluation in evaluations:
            status = "ACCEPT" if evaluation.accepted else "REJECT"
            rho_text = f"{evaluation.predicted_max_rho:.3f}" if evaluation.predicted_max_rho is not None else "n/a"
            print(f"{status:6} {evaluation.expert:16} predicted_max_rho={rho_text} reason={evaluation.reason}")
            write_log({"event": "evaluation", **asdict(evaluation)})

What does the report capture?

  • The list comprehension evaluates all four proposals against the same observation.
  • The terminal report distinguishes accepted proposals from rejected proposals.
  • A missing simulation prediction appears as n/a.
  • The logged record combines the evaluation event name with every field from the data class.
  • Save sandbox.py.
  • Run the harness to expose the first guarded decisions:
python sandbox.py

What should the gate reveal?

The terminal should print one outcome for each expert. The fault_injection row should start with REJECT because its line identifier equals the number of lines.

You should also see harness_log.jsonl in the project file list. It now contains one evaluation record for each proposal.

Missing evaluation rows or the trace file?

  • Confirm that the evaluation block is inside the Grid2Op environment context.
  • Check that the block sits below the complete try and except section.
  • Confirm that write_log() receives an event value of evaluation.

Help me diagnose why my evaluation report or JSONL trace is missing.

The trace now explains the candidates. The remaining block selects one accepted evaluation. It applies the corresponding proposal exactly once through the real environment step.

  • In main(), add the following block immediately below the evaluation loop:
        selected = guarded_coordinator(evaluations)
        selected_proposal = next(proposal for proposal in proposals if proposal.expert == selected.expert)
        selected_action = env.action_space(selected_proposal.command)
        next_observation, reward, done, info = env.step(selected_action)
        result = {"event": "applied_action", "selected_expert": selected.expert, "reward": float(reward), "done": bool(done), "is_illegal": bool(info["is_illegal"]), "is_ambiguous": bool(info["is_ambiguous"]), "exceptions": [str(item) for item in info["exception"]], "resulting_max_rho": None if done else max_rho(next_observation)}
        write_log(result)
        print(f"Guarded selection: {selected.expert}")
        print("Applied action flags: " f"illegal={result['is_illegal']} ambiguous={result['is_ambiguous']} " f"done={result['done']}")
        print(f"Trace written to: {LOG_PATH}")

How does the boundary close?

  • The coordinator returns one accepted evaluation.
  • The harness finds the matching original proposal before constructing its action.
  • The single env.step() call is the only point where a selected proposal changes the shared environment.
  • The final trace record stores the selected expert and the actual action-result flags.

Before you run the completed harness, which expert proposals do you expect the gate to reject? Which accepted proposal do you expect the deterministic ranking to select?

  • Save sandbox.py.
  • Run the complete guarded harness with:
python sandbox.py

What should the completed harness show?

  • The terminal prints EXPECTED FAILURE for the naive coordinator.
  • The fault_injection evaluation prints as REJECT.
  • A Guarded selection: line names one accepted expert.
  • The applied-action flags include illegal=False and ambiguous=False.
  • The final line points to harness_log.jsonl.

Does the final guarded run fail?

  • Confirm that the selection block is outside the evaluation loop.
  • Check that selected_proposal matches the expert name stored in selected.
  • Confirm that the result dictionary uses the diagnostic keys returned through info.

Help me trace the failure between guarded selection, action application, and result logging.

That control boundary is now working. Your experts can propose competing actions while one deterministic harness decides what is safe enough to reach the grid.

Use the reference below to compare your complete sandbox.py file before moving on.

✔️ Awesome, I've got everything!

Your guarded harness now validates, simulates, ranks, applies, and traces proposals. Make sure sandbox.py is saved.

ⓧ I'd like to double check the full code

from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import json

import grid2op

ENV_NAME = "l2rpn_case14_sandbox"
LOG_PATH = Path("harness_log.jsonl")
SAFETY_MARGIN = 0.05


@dataclass
class Proposal:
    expert: str
    task: str
    command: Dict[str, Any]
    rationale: str


@dataclass
class Evaluation:
    expert: str
    task: str
    accepted: bool
    reason: str
    predicted_max_rho: Optional[float]
    predicted_reward: Optional[float]
    predicted_done: Optional[bool]
    command: Dict[str, Any]


def max_rho(observation: Any) -> float:
    values = [float(value) for value in observation.rho if float(value) == float(value)]
    return max(values) if values else 999.0


def connected_line_count(observation: Any) -> int:
    return sum(1 for status in observation.line_status if bool(status))


def fault_injection_expert(env: Any, observation: Any) -> Proposal:
    del observation
    return Proposal("fault_injection", "test the harness boundary", {"set_line_status": [[int(env.n_line), -1]]}, "Submit an out-of-range line identifier to prove the coordinator does not trust experts.")


def conservative_expert(env: Any, observation: Any) -> Proposal:
    del env, observation
    return Proposal("conservative", "preserve the current topology", {}, "Use the do-nothing action as a stable baseline.")


def isolation_expert(env: Any, observation: Any) -> Proposal:
    eligible = [line_id for line_id in range(int(env.n_line)) if bool(observation.line_status[line_id]) and int(observation.time_before_cooldown_line[line_id]) == 0]
    if not eligible:
        return Proposal("isolation", "isolate a highly loaded line", {}, "No connected line is currently actionable, so hold position.")
    line_id = max(eligible, key=lambda item: float(observation.rho[item]))
    return Proposal("isolation", "isolate a highly loaded line", {"set_line_status": [[int(line_id), -1]]}, f"Propose disconnecting actionable line {line_id}, which currently has the highest rho.")


def restoration_expert(env: Any, observation: Any) -> Proposal:
    eligible = [line_id for line_id in range(int(env.n_line)) if not bool(observation.line_status[line_id]) and int(observation.time_before_cooldown_line[line_id]) == 0]
    if not eligible:
        return Proposal("restoration", "restore an available disconnected line", {}, "No disconnected line is currently available for reconnection.")
    line_id = eligible[0]
    return Proposal("restoration", "restore an available disconnected line", {"set_line_status": [[int(line_id), 1]]}, f"Propose reconnecting available line {line_id}.")


def validate_proposal(proposal: Proposal, env: Any, observation: Any) -> Tuple[bool, str]:
    if proposal.command == {}:
        return True, "valid do-nothing command"
    if set(proposal.command) != {"set_line_status"}:
        return False, "unsupported command schema"
    changes = proposal.command["set_line_status"]
    if not isinstance(changes, list) or len(changes) != 1:
        return False, "exactly one line-status change is allowed"
    change = changes[0]
    if not isinstance(change, (list, tuple)) or len(change) != 2:
        return False, "line-status change must contain line_id and status"
    line_id, status = change
    if isinstance(line_id, bool) or not isinstance(line_id, int):
        return False, "line_id must be an integer"
    if line_id < 0 or line_id >= int(env.n_line):
        return False, f"line_id {line_id} is outside 0..{int(env.n_line) - 1}"
    if status not in (-1, 1):
        return False, "status must be -1 or 1"
    if int(observation.time_before_cooldown_line[line_id]) != 0:
        return False, f"line {line_id} is under cooldown"
    if status == -1 and not bool(observation.line_status[line_id]):
        return False, f"line {line_id} is already disconnected"
    if status == 1 and bool(observation.line_status[line_id]):
        return False, f"line {line_id} is already connected"
    return True, "schema and grid-state checks passed"


def evaluate_proposal(proposal: Proposal, env: Any, observation: Any) -> Evaluation:
    valid, reason = validate_proposal(proposal, env, observation)
    if not valid:
        return Evaluation(proposal.expert, proposal.task, False, reason, None, None, None, proposal.command)
    try:
        action = env.action_space(proposal.command)
        simulated_obs, simulated_reward, simulated_done, simulated_info = observation.simulate(action, time_step=0)
    except Exception as exc:
        return Evaluation(proposal.expert, proposal.task, False, f"simulation error: {type(exc).__name__}: {exc}", None, None, None, proposal.command)
    predicted_max_rho = max_rho(simulated_obs)
    illegal = bool(simulated_info["is_illegal"])
    ambiguous = bool(simulated_info["is_ambiguous"])
    if simulated_done:
        accepted, reason = False, "simulation reaches game over"
    elif illegal:
        accepted, reason = False, "simulation flags the action as illegal"
    elif ambiguous:
        accepted, reason = False, "simulation flags the action as ambiguous"
    elif proposal.command and predicted_max_rho > max_rho(observation) + SAFETY_MARGIN:
        accepted, reason = False, "predicted line loading degrades beyond the safety margin"
    else:
        accepted, reason = True, "proposal passed validation and simulation"
    return Evaluation(proposal.expert, proposal.task, accepted, reason, predicted_max_rho, float(simulated_reward), bool(simulated_done), proposal.command)


def naive_coordinator(proposals: List[Proposal], env: Any) -> Any:
    chosen = proposals[0]
    print(f"Naive coordinator trusts: {chosen.expert}")
    return env.action_space(chosen.command)


def guarded_coordinator(evaluations: List[Evaluation]) -> Evaluation:
    accepted = [evaluation for evaluation in evaluations if evaluation.accepted]
    if not accepted:
        raise RuntimeError("The safety gate rejected every proposal.")
    return min(accepted, key=lambda item: (item.predicted_max_rho if item.predicted_max_rho is not None else 999.0, -(item.predicted_reward if item.predicted_reward is not None else -999.0), item.expert))


def write_log(payload: Dict[str, Any]) -> None:
    with LOG_PATH.open("a", encoding="utf-8") as log_file:
        log_file.write(json.dumps(payload, sort_keys=True) + "\n")


def main() -> None:
    if LOG_PATH.exists():
        LOG_PATH.unlink()
    with grid2op.make(ENV_NAME, test=True) as env:
        observation = env.reset(seed=7)
        print("Sandbox ready")
        print(f"Environment: {ENV_NAME}")
        print(f"Initial maximum rho: {max_rho(observation):.3f}")
        print(f"Connected lines: {connected_line_count(observation)}/{int(env.n_line)}")
        proposals = [fault_injection_expert(env, observation), conservative_expert(env, observation), isolation_expert(env, observation), restoration_expert(env, observation)]
        try:
            naive_coordinator(proposals, env)
            print("UNEXPECTED: the naive coordinator accepted the malformed proposal")
        except Exception as exc:
            print(f"EXPECTED FAILURE: {type(exc).__name__}: {exc}")
        evaluations = [evaluate_proposal(proposal, env, observation) for proposal in proposals]
        for evaluation in evaluations:
            status = "ACCEPT" if evaluation.accepted else "REJECT"
            rho_text = f"{evaluation.predicted_max_rho:.3f}" if evaluation.predicted_max_rho is not None else "n/a"
            print(f"{status:6} {evaluation.expert:16} predicted_max_rho={rho_text} reason={evaluation.reason}")
            write_log({"event": "evaluation", **asdict(evaluation)})
        selected = guarded_coordinator(evaluations)
        selected_proposal = next(proposal for proposal in proposals if proposal.expert == selected.expert)
        selected_action = env.action_space(selected_proposal.command)
        next_observation, reward, done, info = env.step(selected_action)
        result = {"event": "applied_action", "selected_expert": selected.expert, "reward": float(reward), "done": bool(done), "is_illegal": bool(info["is_illegal"]), "is_ambiguous": bool(info["is_ambiguous"]), "exceptions": [str(item) for item in info["exception"]], "resulting_max_rho": None if done else max_rho(next_observation)}
        write_log(result)
        print(f"Guarded selection: {selected.expert}")
        print("Applied action flags: " f"illegal={result['is_illegal']} ambiguous={result['is_ambiguous']} " f"done={result['done']}")
        print(f"Trace written to: {LOG_PATH}")


if __name__ == "__main__":
    main()

How should you use this reference?

Compare the function order, indentation, and identifier spelling with your saved file. The reference includes the complete guarded harness for this step.

Secret mission

Secret Mission: Rank the Experts from Their Traces

Turn the JSONL audit trail into an offline reliability scorecard. You will rank each expert by acceptance rate and predicted line loading without importing or running Grid2Op.

Clean Up Your Resources

Clean Up Your Resources

Choose whether to keep the local harness, pause its active virtual environment, or delete it entirely. Everything runs locally, so there are no ongoing costs or cloud resources.

Resources you used:

  • One local project folder.
  • A Python virtual environment in .venv containing Grid2Op 1.12.5.
  • The dependency file requirements.txt.
  • The guarded harness in sandbox.py.
  • The replayable trace in harness_log.jsonl.
  • The optional expert-ranking analyzer in analyze_log.py.

Keep everything running

No action needed. Choose this if you want to continue experimenting with the guarded harness.

  • Keep the local project folder in its current location.
  • Keep .venv available for the local-LLM follow-on project.

Your harness remains ready to run. Keeping these local resources costs $0.

Pause - I'll come back to this later

Leave the active virtual environment while keeping every project file available for later.

  • Switch back to the PowerShell terminal in Visual Studio Code.
  • Deactivate the virtual environment by running this command:
deactivate

What Does This Command Do?

The command exits the active Python virtual environment.

Your project files stay in place. The packages inside .venv also remain available.

  • Review the PowerShell prompt.

You'll see that the virtual-environment prefix has disappeared from the prompt.

Command Does Not Run?

The environment may already be inactive. A prompt without the virtual-environment prefix confirms the paused state.

Help me confirm whether my Python virtual environment is active.

Delete - I don't want to use this again

This option removes all project resources so you can start fresh. The deletion is permanent.

Every artifact is local. No remote infrastructure is affected.

  • Close Visual Studio Code.
  • In File Explorer, locate the project folder that contains sandbox.py.
  • Remove the project folder in File Explorer.
  • Confirm the project folder no longer appears in File Explorer.

Cleanup is complete. The local harness has been removed from your computer.

Nice Work!

Nice Work!

Mission accomplished! Your guarded power-grid harness now controls which expert proposal can reach the shared simulation.

You've learned how to:

  • Run a packaged Grid2Op sandbox that reports line loading plus connected-line counts from a fixed reset.
  • Build a proposal-based swarm whose specialized experts cannot modify the shared environment directly. Expose the naive coordinator's failure with an out-of-range proposal from the fault-injection expert.
  • Protect the simulator with schema checks plus grid-state checks. Preview each valid proposal before applying one deterministic selection. Record every decision in a replayable JSONL trace.
  • Secret Mission: Build an offline expert-reliability scorecard from harness_log.jsonl without importing or running Grid2Op. Rank each expert by acceptance rate plus average predicted maximum rho.

Ready to quiz yourself?