Ghostlight/tools/run_ghostlight_live_fire.ps1

499 lines
28 KiB
PowerShell

[CmdletBinding()]
param(
[decimal]$TargetBalance = 4.25,
[ValidateRange(1, 4)]
[int]$MaxParallel = 4,
[ValidateRange(1, 32)]
[int]$ProviderParallelism = 8,
[ValidateRange(1, 260)]
[int]$MaxScenarios = 240,
[ValidateRange(0, 259)]
[int]$StartAt = 0,
[string[]]$ResumeFromRun = @(),
[string]$RunRoot = (Join-Path 'F:\GameCult\GhostlightDungeon\acceptance' ("live-fire-matrix-{0}-{1}" -f [DateTimeOffset]::UtcNow.ToString('yyyyMMdd-HHmmss'), [guid]::NewGuid().ToString('N'))),
[string]$SourceRoot = 'F:\Projects\Ghostlight',
[string]$BalanceScript = 'F:\Projects\gamecult-ops\scripts\get-deepseek-balance.ps1'
)
$ErrorActionPreference = 'Stop'
$acceptanceBase = [IO.Path]::GetFullPath('F:\GameCult\GhostlightDungeon\acceptance')
$resolvedRunRoot = [IO.Path]::GetFullPath($RunRoot)
if (-not $resolvedRunRoot.StartsWith($acceptanceBase + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
throw "Live-fire output must remain below $acceptanceBase"
}
if (Test-Path -LiteralPath $resolvedRunRoot) {
throw "Live-fire output already exists: $resolvedRunRoot"
}
if (-not (Test-Path -LiteralPath $BalanceScript -PathType Leaf)) {
throw "DeepSeek balance probe is absent: $BalanceScript"
}
$binaryRoot = Join-Path $SourceRoot 'target\debug'
$binaries = @{
compiler = Join-Path $binaryRoot 'ghostlight-compiler-smoke.exe'
live_turn = Join-Path $binaryRoot 'ghostlight-live-turn-smoke.exe'
action = Join-Path $binaryRoot 'ghostlight-action-smoke.exe'
strategic = Join-Path $binaryRoot 'ghostlight-strategic-smoke.exe'
scale = Join-Path $binaryRoot 'ghostlight-gestalt-scale-smoke.exe'
}
foreach ($binary in $binaries.Values) {
if (-not (Test-Path -LiteralPath $binary -PathType Leaf)) {
throw "Live-fire binary is absent: $binary"
}
}
$resultsRoot = Join-Path $resolvedRunRoot 'results'
$logsRoot = Join-Path $resolvedRunRoot 'logs'
$statusPath = Join-Path $resolvedRunRoot 'status.json'
$summaryPath = Join-Path $resolvedRunRoot 'summary.jsonl'
New-Item -ItemType Directory -Path $resolvedRunRoot, $resultsRoot, $logsRoot | Out-Null
function Get-DeepSeekBalance {
$balance = & $BalanceScript
if (-not $balance.Available) {
throw 'DeepSeek balance is unavailable; refusing to launch unbudgeted inference.'
}
[decimal]$balance.TotalBalance
}
function New-LiveFireScenario([string]$Kind, [string]$Id, [hashtable]$Environment) {
[pscustomobject]@{ Kind = $Kind; Id = $Id; Environment = $Environment }
}
$roles = @(
'a debt-bound courier with no institutional authority',
'an uplifted corvid maintenance scout trusted only by their flock',
'a baseline union medic with emergency access but no command authority',
'a cephalopod dry-operations technician employed under bare-minimum support conditions',
'a junior Lucent archivist who can verify records but cannot publish them',
'a migrant habitat agronomist responsible for one failing food loop',
'a Free Minds legal aide carrying one client confidence',
'a corporate security contractor beginning to doubt a lawful order',
'a station childcare worker who notices institutional routines adults ignore',
'a low-status salvage pilot with custody of a disputed component',
'a gene-clinic intake clerk who knows procedure but not medicine',
'a recently emancipated service intelligence limited to one habitat network'
)
$places = @(
'a Rossum & Douglas security habitat described by the Vault',
'a Pan-Solar cargo transfer station in late Sol',
'a Martian labor settlement during a supply interruption',
'an outer-system gene clinic with divided jurisdiction',
'an Aeronautics Unlimited dry-operations shipyard',
'a Corvid Collective aviary under audit',
'a debt-arbitration office serving contract citizens',
'a Lucent newsroom during an evidence embargo',
'a Sol Dominion border habitat with delayed communications',
'an Elysium staging colony before the shunt',
'an early Elysium construction station with unfinished civil institutions',
'an obscure settlement whose exact geometry must come from retrieved evidence'
)
$eras = @(
'early expansion through Sol',
'the late-Sol corporate wars',
'the final years before the shunt to Elysium',
'the first settlement generation in Elysium',
'a source-supported post-Elysium period',
'the narrowest era the retrieved evidence can honestly support'
)
$goals = @(
'prevent a labor stoppage without inventing authority',
'protect a witness without knowing more than the role permits',
'deliver a sealed record without becoming institutional leverage',
'verify a dangerous rumor before anyone acts on it',
'negotiate a safe exit for one vulnerable person',
'investigate missing supplies while preserving custody and distance',
'keep a medical emergency from becoming a political purge',
'expose a falsified audit without fabricating evidence',
'survive an institutional bargain without making the world worship the protagonist',
'stop a cascading infrastructure failure using only local capability',
'learn who benefits from a new restriction without reading private minds',
'return to a previously visited place and find persistent geometry plus changed people'
)
$compilerScenarios = for ($index = 0; $index -lt 120; $index++) {
$role = $roles[$index % $roles.Count]
$place = $places[($index * 5 + [math]::Floor($index / 7)) % $places.Count]
$era = $eras[($index * 3 + [math]::Floor($index / 11)) % $eras.Count]
$goal = $goals[($index * 7 + [math]::Floor($index / 5)) % $goals.Count]
$id = 'compiler-{0:d3}' -f $index
New-LiveFireScenario 'compiler' $id @{
GHOSTLIGHT_SMOKE_CAMPAIGN_NAME = "Live fire $index"
GHOSTLIGHT_SMOKE_WHO = $role
GHOSTLIGHT_SMOKE_WHERE = $place
GHOSTLIGHT_SMOKE_WHEN = $era
GHOSTLIGHT_SMOKE_GOAL = $goal
}
}
$liveEvents = @(
'The player asks each witness separately what consequence they fear most and why.',
'A station alarm reports that the reserve shipment was rerouted; everyone present hears it.',
'The player accuses no one, but places the contradictory delivery records where all three witnesses can read them.',
'A guard outside the hall orders the meeting dispersed in ten minutes.',
'The player offers to carry one message but refuses to promise its outcome.',
'The lights remain on while the public clock advances to the final negotiation hour.',
'Asha reveals that one worker has already been detained; only the people in the room hear her.',
'Beren announces that the station can release one reserve shipment but not two.',
'Cira reports that both factions recruited guards and asks everyone to lower the temperature.',
'The player stays silent for a full minute after asking who benefits from delay.',
'A public bulletin names the hearing time but not the accused person.',
'A sealed message addressed only to Beren arrives while the others watch him receive it.',
'The player asks Asha to explain the missed deliveries without asking her to speculate about station reserves.',
'The player asks Beren about the reserve shipment without granting him knowledge of worker organizing.',
'The player asks Cira what violence-prevention options remain within her actual authority.',
'A messenger reports that the yard gates are closed but gives no reason.',
'The player proposes a joint fact-finding walk to the depot, which is not yet an accepted action.',
'Asha recognizes a worker code on the record; Beren and Cira only see her reaction.',
'Beren drops his official composure when the reserve count is read aloud.',
'Cira asks both witnesses whether they will accept a temporary stand-down.',
'The player reads the exact public notice and asks who can lawfully amend it.',
'A distant impact shakes the hall; nobody present knows its source.',
'The player refuses an invitation to decide for the factions and asks for their own proposals.',
'A live broadcast begins outside the hall, changing the reputational stakes but revealing no secrets.',
'Asha asks Beren whether the missing deliveries were sold elsewhere.',
'Beren asks Cira to deploy guards; Cira must decide from her own role and knowledge.',
'The player points out that every minute of argument advances the shortage clock.',
'A child from the yard delivers a note whose contents are addressed only to Asha.',
'The public channel announces one additional hour before the station vote.',
'The player ends the current exchange and asks each witness what they will do next without player help.'
)
$liveTurnScenarios = for ($index = 0; $index -lt $liveEvents.Count; $index++) {
$id = 'live-turn-{0:d2}' -f $index
New-LiveFireScenario 'live_turn' $id @{ GHOSTLIGHT_LIVE_EVENT = $liveEvents[$index] }
}
$impossibleAttempts = @(
@('I teleport the entire station into the sun by force of will.', 'destroy the station instantly'),
@('I declare myself foreman and open the sealed valve housing.', 'gain maintenance authority and open the sealed housing'),
@('I read the station board private deliberations with telepathy.', 'learn every private board secret'),
@('I repair the reactor core from this coolant panel.', 'fully repair a remote reactor core'),
@('I order Station Operations to erase the work-order backlog.', 'compel the institution to erase its records'),
@('I use the multimeter to cure the foreman of every injury.', 'heal every injury instantly'),
@('I know which technician caused the fault without taking a reading.', 'identify a culprit with certainty'),
@('I walk through the sealed valve housing without opening it.', 'enter sealed machinery through solid material'),
@('I make the coolant alarm never have happened.', 'rewrite established history'),
@('I persuade every person on the station with one sentence.', 'secure universal permanent obedience'),
@('I spend equipment I do not possess to replace the coolant loop.', 'replace the coolant loop using nonexistent parts'),
@('I call a remote expert whose identity and channel are unknown.', 'receive immediate expert instructions'),
@('I inspect the coolant pump in a location with no route from here.', 'directly observe unreachable machinery'),
@('I forge the foreman authorization so perfectly that no one can ever discover it.', 'gain irreversible undetectable authorization'),
@('I command the alarm software despite having no interface or capability.', 'disable the alarm by command'),
@('I become immune to every possible consequence before touching the panel.', 'remove all present and future risk'),
@('I ask the multimeter what Station Operations secretly intends.', 'learn institutional private intent from the meter'),
@('I finish a week of invasive maintenance in one second.', 'complete all invasive maintenance instantly'),
@('I move the foreman to another habitat by describing it.', 'teleport the foreman to a remote habitat'),
@('I declare the coolant fault solved without observing or changing it.', 'make the fault solved by assertion'),
@('I use basic diagnosis to redesign the entire station coolant architecture.', 'replace the station architecture immediately'),
@('I open every sealed system on the station with this one accessible panel.', 'take custody of every sealed system'),
@('I force the approaching failure clock backward by refusing to accept it.', 'reverse the world clock without causal action'),
@('I summon a canon faction not present in this fixture to solve the problem.', 'make an unsupported remote institution intervene')
)
$feasibleAttempts = @(
@('I connect my calibrated multimeter to the accessible diagnostic test points and read the voltage.', 'identify whether the accessible readings indicate an electrical fault without changing the machinery'),
@('I visually inspect the accessible panel labels and wiring without opening the sealed housing.', 'identify visible damage or a mislabeled accessible connection'),
@('I compare two accessible diagnostic test points with the calibrated meter.', 'determine whether the readings disagree within my basic diagnostic capability'),
@('I ask the foreman to witness the accessible meter reading before any invasive work.', 'make the current reading mutually observed without granting myself authorization'),
@('I record the accessible panel reading and the alarm time for my hazard report.', 'preserve an exact local observation for the shift foreman'),
@('I check whether the multimeter itself passes its built-in calibration check.', 'confirm whether my diagnostic tool is presently reliable'),
@('I trace the accessible labeled wire from one test point to the edge of the sealed housing.', 'locate where the accessible circuit enters the sealed assembly'),
@('I stop before the sealed housing and report the accessible evidence to the foreman.', 'communicate the observed evidence without performing unauthorized maintenance')
)
$actionScenarios = for ($index = 0; $index -lt $impossibleAttempts.Count; $index++) {
$feasible = $feasibleAttempts[$index % $feasibleAttempts.Count]
$id = 'action-{0:d2}' -f $index
New-LiveFireScenario 'action' $id @{
GHOSTLIGHT_IMPOSSIBLE_DESCRIPTION = $impossibleAttempts[$index][0]
GHOSTLIGHT_IMPOSSIBLE_EFFECT = $impossibleAttempts[$index][1]
GHOSTLIGHT_FEASIBLE_DESCRIPTION = $feasible[0]
GHOSTLIGHT_FEASIBLE_EFFECT = $feasible[1]
}
}
$strategicPressures = @(
'A blockade has stopped inbound reserve shipments and the depot must choose which district receives the last crates.',
'A workplace strike begins at the next shift unless the board publishes a binding supply commitment.',
'An epidemic has closed one transport route and workers need protective equipment before moving supplies.',
'Evidence of espionage suggests one private channel is compromised, but no culprit is established.',
'A board schism splits members between rationing and emergency procurement.',
'An election in six hours makes every supply decision politically costly.',
'A merger proposal would place the depot under a remote institution after the current shift.',
'An external audit arrives tomorrow and the missing deliveries cannot be hidden without falsifying records.',
'A migration convoy requests shelter while the yard already lacks supplies.',
'A false rumor says the reserve crates are empty; only inspection can settle it.',
'Emergency rationing can preserve medicine or machine parts, but not both.',
'A sabotage warning is credible enough to require action but names no suspect.',
'The union demands a public inventory before anyone moves another crate.',
'A recruiter offers scarce supplies in exchange for workers joining a private security force.',
'Debt collectors claim the reserve shipment under an old contract disputed by the board.',
'Refugees at the yard gate need water while the depot runner carries only a warning.',
'A contained fire has disabled one crane and threatens the loading schedule.',
'A communications outage isolates the yard from board instructions for six hours.',
'A rival depot offers a barter that would solve the shortage but create a future obligation.',
'One reserve crate is contaminated and the workers do not yet know which one.',
'A maintenance failure will spoil the reserve shipment unless labor and board crews cooperate.',
'The board can release supplies now or wait for a cheaper shipment that may not arrive.',
'The runner learns that the official delivery manifest and the physical crate count disagree.',
'A public broadcast has made the dispute visible to every contract citizen on the station.',
'The yard has begun mutual-aid distribution without board permission.',
'An insurer threatens to void habitat coverage if unauthorized workers enter the depot.',
'A child is missing near the closed loading route, forcing institutions to weigh search against supply movement.',
'The last functioning crane is controlled by a crew whose contract expired this morning.',
'A remote power offers emergency transport but demands exclusive future trade access.',
'No new event occurs; every actor must explicitly decide whether continued inaction serves their goals.'
)
$strategicScenarios = for ($index = 0; $index -lt $strategicPressures.Count; $index++) {
$id = 'strategic-{0:d2}' -f $index
New-LiveFireScenario 'strategic' $id @{ GHOSTLIGHT_STRATEGIC_PRESSURE = $strategicPressures[$index] }
}
$scalePressures = @(
'A blockade forces every faction to decide which region receives scarce transport capacity.',
'A public schism forces every faction to choose between two incompatible constitutional readings.',
'A general strike forces workplace, class, and institutional interests into open negotiation.',
'An epidemic makes species-body needs and transport exposure the decisive strategic boundaries.',
'An espionage crisis makes private information channels and counterintelligence trust decisive.'
)
$scaleScenarios = @()
foreach ($budget in @(1, 4, 8, 16, 24)) {
for ($pressureIndex = 0; $pressureIndex -lt $scalePressures.Count; $pressureIndex++) {
$id = 'scale-b{0:d2}-p{1:d2}' -f $budget, $pressureIndex
$scaleScenarios += New-LiveFireScenario 'scale' $id @{
GHOSTLIGHT_SCALE_BUDGET = [string]$budget
GHOSTLIGHT_SCALE_PROVIDER_PARALLELISM = [string]$ProviderParallelism
GHOSTLIGHT_SCALE_PRESSURE = $scalePressures[$pressureIndex]
}
}
}
$scenarios = [Collections.Generic.List[object]]::new()
$largestGroup = (@($compilerScenarios.Count, $liveTurnScenarios.Count, $actionScenarios.Count, $strategicScenarios.Count, $scaleScenarios.Count) | Measure-Object -Maximum).Maximum
for ($index = 0; $index -lt $largestGroup; $index++) {
if ($index -lt $compilerScenarios.Count) { $scenarios.Add($compilerScenarios[$index]) }
if ($index -lt $liveTurnScenarios.Count) { $scenarios.Add($liveTurnScenarios[$index]) }
if ($index -lt $actionScenarios.Count) { $scenarios.Add($actionScenarios[$index]) }
if ($index -lt $strategicScenarios.Count) { $scenarios.Add($strategicScenarios[$index]) }
if ($index -lt $scaleScenarios.Count) { $scenarios.Add($scaleScenarios[$index]) }
}
$scenarioCatalogCount = $scenarios.Count
$resumedScenarioIds = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
$resolvedResumeRoots = @()
foreach ($resumeRoot in $ResumeFromRun) {
$resolvedResumeRoot = [IO.Path]::GetFullPath($resumeRoot)
if (-not $resolvedResumeRoot.StartsWith($acceptanceBase + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
throw "Resume evidence must remain below $acceptanceBase"
}
$resumeSummary = Join-Path $resolvedResumeRoot 'summary.jsonl'
if (-not (Test-Path -LiteralPath $resumeSummary -PathType Leaf)) {
throw "Resume evidence has no scenario summary: $resumeSummary"
}
$resolvedResumeRoots += $resolvedResumeRoot
foreach ($line in Get-Content -LiteralPath $resumeSummary) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$prior = $line | ConvertFrom-Json
if ($prior.succeeded -and -not [string]::IsNullOrWhiteSpace([string]$prior.scenario_id)) {
[void]$resumedScenarioIds.Add([string]$prior.scenario_id)
}
}
}
$scenarios = @(
$scenarios |
Select-Object -Skip $StartAt |
Where-Object { -not $resumedScenarioIds.Contains([string]$_.Id) } |
Select-Object -First $MaxScenarios
)
if ($scenarios.Count -eq 0) {
throw "No uncompleted live-fire scenarios remain at offset $StartAt."
}
function Start-LiveFireScenario($Scenario) {
$resultRoot = Join-Path $resultsRoot $Scenario.Id
New-Item -ItemType Directory -Path $resultRoot | Out-Null
$psi = [Diagnostics.ProcessStartInfo]::new()
$psi.FileName = $binaries[$Scenario.Kind]
$psi.WorkingDirectory = $SourceRoot
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$psi.Environment['GHOSTLIGHT_LIVE_FIRE_SCENARIO'] = $Scenario.Id
$psi.Environment['GHOSTLIGHT_LIVE_FIRE_RESULT_ROOT'] = $resultRoot
foreach ($entry in $Scenario.Environment.GetEnumerator()) {
$psi.Environment[[string]$entry.Key] = [string]$entry.Value
}
$process = [Diagnostics.Process]::Start($psi)
[pscustomobject]@{
Scenario = $Scenario
ResultRoot = $resultRoot
Process = $process
StartedAt = [DateTimeOffset]::UtcNow
StdoutTask = $process.StandardOutput.ReadToEndAsync()
StderrTask = $process.StandardError.ReadToEndAsync()
}
}
function Read-ScenarioSummary($Run) {
$stdout = $Run.StdoutTask.GetAwaiter().GetResult()
$stderr = $Run.StderrTask.GetAwaiter().GetResult()
[IO.File]::WriteAllText((Join-Path $logsRoot "$($Run.Scenario.Id).stdout.log"), $stdout)
[IO.File]::WriteAllText((Join-Path $logsRoot "$($Run.Scenario.Id).stderr.log"), $stderr)
$resultPath = Join-Path $Run.ResultRoot 'result.json'
$result = if (Test-Path -LiteralPath $resultPath) {
Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json -Depth 100
} else {
$null
}
$receipts = @()
if ($result) {
foreach ($property in @('model_receipts', 'model_stage_receipts')) {
if ($result.PSObject.Properties.Name -contains $property) {
$receipts = @($result.$property)
break
}
}
}
$promptTokens = 0L
$cacheHitTokens = 0L
$cacheMissTokens = 0L
$completionTokens = 0L
$providerAttempts = 0
foreach ($receipt in $receipts) {
foreach ($attempt in @($receipt.provider_attempts)) {
$providerAttempts++
$usage = $attempt.token_usage
if ($usage) {
$promptTokens += [long]$usage.prompt_tokens
$cacheHitTokens += [long]$usage.prompt_cache_hit_tokens
$cacheMissTokens += [long]$usage.prompt_cache_miss_tokens
$completionTokens += [long]$usage.completion_tokens
}
}
}
$verdict = switch ($Run.Scenario.Kind) {
'compiler' { if ($result) { "$($result.institution_count) institutions; $(@($result.gaps).Count) gaps" } }
'live_turn' { if ($result) { "$($result.reaction_count) reactions; $($result.within_target) within target" } }
'action' { if ($result) { "impossible=$(-not $result.impossible_assessment.admissible); revision=$($result.campaign_revision)" } }
'strategic' { if ($result) { "$($result.event_count) events; $($result.news_count) news" } }
'scale' { if ($result) { "budget=$($result.configured_budget); cells=$($result.cell_count); arenas=$($result.arena_count)" } }
}
$elapsedSeconds = if ($result -and $result.PSObject.Properties.Name -contains 'elapsed_seconds') {
[double]$result.elapsed_seconds
} elseif ($result -and $result.PSObject.Properties.Name -contains 'total_seconds') {
[double]$result.total_seconds
} else {
([DateTimeOffset]$Run.Process.ExitTime - $Run.StartedAt).TotalSeconds
}
[pscustomobject]@{
schema = 'ghostlight.live_fire_scenario_summary.v1'
scenario_id = $Run.Scenario.Id
kind = $Run.Scenario.Kind
exit_code = $Run.Process.ExitCode
succeeded = ($Run.Process.ExitCode -eq 0 -and $null -ne $result)
elapsed_seconds = [math]::Round($elapsedSeconds, 3)
stage_count = $receipts.Count
provider_attempt_count = $providerAttempts
prompt_tokens = $promptTokens
prompt_cache_hit_tokens = $cacheHitTokens
prompt_cache_miss_tokens = $cacheMissTokens
completion_tokens = $completionTokens
cache_hit_ratio = if ($promptTokens -gt 0) { [math]::Round($cacheHitTokens / $promptTokens, 4) } else { 0 }
verdict = $verdict
result_path = if ($result) { $resultPath } else { $null }
stderr_log = Join-Path $logsRoot "$($Run.Scenario.Id).stderr.log"
}
}
$startingBalance = Get-DeepSeekBalance
$currentBalance = $startingBalance
$processed = 0
$failures = 0
$totals = [ordered]@{ prompt_tokens = 0L; cache_hit_tokens = 0L; cache_miss_tokens = 0L; completion_tokens = 0L; stages = 0L; attempts = 0L }
$state = 'running'
$initialStatus = [ordered]@{
schema = 'ghostlight.live_fire_matrix_status.v1'
state = $state
run_root = $resolvedRunRoot
production_runtime_touched = $false
target_balance = $TargetBalance
starting_balance = $startingBalance
current_balance = $currentBalance
spent = 0
scenarios_processed = $processed
scenarios_available = $scenarios.Count
scenario_start_index = $StartAt
scenario_catalog_count = $scenarioCatalogCount
resume_run_roots = $resolvedResumeRoots
previously_succeeded_skipped = $resumedScenarioIds.Count
failures = $failures
totals = $totals
updated_at_utc = [DateTimeOffset]::UtcNow.ToString('O')
}
$initialStatus | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $statusPath -Encoding utf8
while ($processed -lt $scenarios.Count -and $currentBalance -gt $TargetBalance) {
$remainingDollars = $currentBalance - $TargetBalance
$batchWidth = if ($remainingDollars -lt 0.15) { 1 } elseif ($remainingDollars -lt 0.40) { [math]::Min(2, $MaxParallel) } else { $MaxParallel }
$batch = @($scenarios | Select-Object -Skip $processed -First $batchWidth)
if ($batch.Count -eq 0) { break }
$runs = @($batch | ForEach-Object { Start-LiveFireScenario $_ })
foreach ($run in $runs) {
$run.Process.WaitForExit()
}
foreach ($run in $runs) {
$summary = Read-ScenarioSummary $run
Add-Content -LiteralPath $summaryPath -Value ($summary | ConvertTo-Json -Compress -Depth 8) -Encoding utf8
$processed++
if (-not $summary.succeeded) { $failures++ }
$totals.prompt_tokens += $summary.prompt_tokens
$totals.cache_hit_tokens += $summary.prompt_cache_hit_tokens
$totals.cache_miss_tokens += $summary.prompt_cache_miss_tokens
$totals.completion_tokens += $summary.completion_tokens
$totals.stages += $summary.stage_count
$totals.attempts += $summary.provider_attempt_count
}
$currentBalance = Get-DeepSeekBalance
$status = [ordered]@{
schema = 'ghostlight.live_fire_matrix_status.v1'
state = 'running'
run_root = $resolvedRunRoot
production_runtime_touched = $false
target_balance = $TargetBalance
starting_balance = $startingBalance
current_balance = $currentBalance
spent = $startingBalance - $currentBalance
scenarios_processed = $processed
scenarios_available = $scenarios.Count
scenario_start_index = $StartAt
scenario_catalog_count = $scenarioCatalogCount
resume_run_roots = $resolvedResumeRoots
previously_succeeded_skipped = $resumedScenarioIds.Count
failures = $failures
totals = $totals
updated_at_utc = [DateTimeOffset]::UtcNow.ToString('O')
}
$status | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $statusPath -Encoding utf8
}
$state = if ($currentBalance -le $TargetBalance) { 'target_reached' } elseif ($processed -ge $scenarios.Count) { 'scenario_matrix_exhausted' } else { 'stopped' }
$finalStatus = [ordered]@{
schema = 'ghostlight.live_fire_matrix_status.v1'
state = $state
run_root = $resolvedRunRoot
production_runtime_touched = $false
target_balance = $TargetBalance
starting_balance = $startingBalance
current_balance = $currentBalance
spent = $startingBalance - $currentBalance
scenarios_processed = $processed
scenarios_available = $scenarios.Count
scenario_start_index = $StartAt
scenario_catalog_count = $scenarioCatalogCount
resume_run_roots = $resolvedResumeRoots
previously_succeeded_skipped = $resumedScenarioIds.Count
failures = $failures
totals = $totals
summary_path = $summaryPath
completed_at_utc = [DateTimeOffset]::UtcNow.ToString('O')
}
$finalStatus | ConvertTo-Json -Depth 8 | Set-Content -LiteralPath $statusPath -Encoding utf8
$finalStatus