microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1873-devcontainer

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/evals/Modules/VallyRunner.psm1

486lines · modecode

1# Copyright (c) Microsoft Corporation.
2# SPDX-License-Identifier: MIT
3
4# VallyRunner.psm1
5#
6# Purpose: Spawn `vally eval` for a single spec, locate the timestamped run
7# directory vally writes under --output-dir, and aggregate the
8# resulting results.jsonl into pass/fail counts suitable for the
9# PR-time eval-summary report.
10# Author: HVE Core Team
11
12#Requires -Version 7.0
13
14Set-StrictMode -Version Latest
15
16function Resolve-VallyRunDir {
17 <#
18 .SYNOPSIS
19 Returns the most recently written subdirectory of an `--output-dir`.
20
21 .DESCRIPTION
22 `vally eval` writes each invocation under a timestamped subdirectory of
23 the directory passed to `--output-dir`. Callers need the latest such
24 directory to locate `results.jsonl`.
25
26 .PARAMETER OutputDir
27 Directory that was passed to `vally eval --output-dir`.
28
29 .OUTPUTS
30 [string] Full path to the newest subdirectory, or $null when none exists.
31 #>
32 [CmdletBinding()]
33 [OutputType([string])]
34 param(
35 [Parameter(Mandatory = $true)]
36 [string]$OutputDir
37 )
38
39 if (-not (Test-Path -LiteralPath $OutputDir -PathType Container)) { return $null }
40
41 $latest = Get-ChildItem -LiteralPath $OutputDir -Directory -ErrorAction SilentlyContinue |
42 Sort-Object LastWriteTime -Descending |
43 Select-Object -First 1
44
45 if (-not $latest) { return $null }
46 return $latest.FullName
47}
48
49function Read-VallyResultsJsonl {
50 <#
51 .SYNOPSIS
52 Aggregates trial outcomes from a vally `results.jsonl` file.
53
54 .DESCRIPTION
55 Reads the `results.jsonl` written by `vally eval` (located under the run
56 directory returned by `Resolve-VallyRunDir`) and tallies passing/failing
57 trials plus aggregate wall time. Malformed lines are skipped rather than
58 thrown so a partial run still yields counts.
59
60 .PARAMETER RunDir
61 Directory returned by `Resolve-VallyRunDir`.
62
63 .OUTPUTS
64 [hashtable] `@{ assertionsPassed; assertionsFailed; durationMs; trials; resultsPath; perStimulus }`.
65 `perStimulus` is an ordered map keyed by stimulus name with `@{ assertionsPassed; assertionsFailed; durationMs; trials }`.
66 #>
67 [CmdletBinding()]
68 [OutputType([hashtable])]
69 param(
70 [Parameter(Mandatory = $true)]
71 [AllowNull()]
72 [AllowEmptyString()]
73 [string]$RunDir
74 )
75
76 $empty = @{
77 assertionsPassed = 0
78 assertionsFailed = 0
79 durationMs = 0
80 trials = 0
81 resultsPath = $null
82 perStimulus = [ordered]@{}
83 }
84
85 if ([string]::IsNullOrWhiteSpace($RunDir) -or -not (Test-Path -LiteralPath $RunDir -PathType Container)) {
86 return $empty
87 }
88
89 $jsonl = Get-ChildItem -LiteralPath $RunDir -Filter 'results.jsonl' -Recurse -File -ErrorAction SilentlyContinue |
90 Select-Object -First 1
91 if (-not $jsonl) { return $empty }
92
93 $passed = 0
94 $failed = 0
95 $durationMs = 0
96 $trials = 0
97 $perStimulus = [ordered]@{}
98
99 foreach ($line in Get-Content -LiteralPath $jsonl.FullName -Encoding utf8) {
100 if ([string]::IsNullOrWhiteSpace($line)) { continue }
101 try {
102 $obj = $line | ConvertFrom-Json -Depth 100
103 }
104 catch {
105 continue
106 }
107
108 $trials++
109
110 $trialPassed = $false
111 if ($obj.PSObject.Properties['gradeResult'] -and $obj.gradeResult -and
112 $obj.gradeResult.PSObject.Properties['passed'] -and $null -ne $obj.gradeResult.passed) {
113 $trialPassed = [bool]$obj.gradeResult.passed
114 }
115 if ($trialPassed) { $passed++ } else { $failed++ }
116
117 $trialWallMs = 0
118 if ($obj.PSObject.Properties['trajectory'] -and $obj.trajectory -and
119 $obj.trajectory.PSObject.Properties['metrics'] -and $obj.trajectory.metrics -and
120 $obj.trajectory.metrics.PSObject.Properties['wallTimeMs'] -and
121 $null -ne $obj.trajectory.metrics.wallTimeMs) {
122 $trialWallMs = [int]$obj.trajectory.metrics.wallTimeMs
123 $durationMs += $trialWallMs
124 }
125
126 $stimulusName = $null
127 if ($obj.PSObject.Properties['trajectory'] -and $obj.trajectory -and
128 $obj.trajectory.PSObject.Properties['stimulus'] -and $obj.trajectory.stimulus -and
129 $obj.trajectory.stimulus.PSObject.Properties['name'] -and
130 -not [string]::IsNullOrWhiteSpace([string]$obj.trajectory.stimulus.name)) {
131 $stimulusName = [string]$obj.trajectory.stimulus.name
132 }
133
134 if ($stimulusName) {
135 if (-not $perStimulus.Contains($stimulusName)) {
136 $perStimulus[$stimulusName] = @{
137 assertionsPassed = 0
138 assertionsFailed = 0
139 durationMs = 0
140 trials = 0
141 }
142 }
143 $bucket = $perStimulus[$stimulusName]
144 $bucket.trials++
145 if ($trialPassed) { $bucket.assertionsPassed++ } else { $bucket.assertionsFailed++ }
146 $bucket.durationMs += $trialWallMs
147 }
148 }
149
150 return @{
151 assertionsPassed = $passed
152 assertionsFailed = $failed
153 durationMs = $durationMs
154 trials = $trials
155 resultsPath = $jsonl.FullName
156 perStimulus = $perStimulus
157 }
158}
159
160function Invoke-VallySpec {
161 <#
162 .SYNOPSIS
163 Runs `vally eval` for a single spec and returns aggregated outcomes.
164
165 .DESCRIPTION
166 Invokes the configured vally executable with `eval --eval-spec --model
167 --output-dir`, captures stdout/stderr (optionally tee'd to a log file),
168 resolves the timestamped run directory under `OutputDir`, and aggregates
169 the `results.jsonl` via `Read-VallyResultsJsonl`.
170
171 .PARAMETER SpecPath
172 Path to the eval spec YAML file.
173
174 .PARAMETER OutputDir
175 Directory passed to `vally eval --output-dir`. Created if it does not exist.
176
177 .PARAMETER Model
178 Model passed to `vally eval --model`.
179
180 .PARAMETER VallyCommand
181 Path or name of the vally executable. Defaults to `vally`. Tests override
182 this with the stub fixture path.
183
184 .PARAMETER LogPath
185 Optional path to tee stdout/stderr to a log file.
186
187 .OUTPUTS
188 [hashtable] `@{ specPath; exitCode; runDir; assertionsPassed; assertionsFailed; durationMs; trials; resultsPath; perStimulus }`.
189 #>
190 [CmdletBinding()]
191 [OutputType([hashtable])]
192 param(
193 [Parameter(Mandatory = $true)][string]$SpecPath,
194 [Parameter(Mandatory = $true)][string]$OutputDir,
195 [Parameter(Mandatory = $true)][string]$Model,
196 [string]$VallyCommand = 'vally',
197 [string]$LogPath
198 )
199
200 if (-not (Test-Path -LiteralPath $OutputDir)) {
201 New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
202 }
203
204 $vallyArgs = @(
205 'eval'
206 '--eval-spec', $SpecPath
207 '--model', $Model
208 '--output-dir', $OutputDir
209 )
210
211 $sw = [System.Diagnostics.Stopwatch]::StartNew()
212 $prev = [Console]::OutputEncoding
213 $exitCode = 0
214 try {
215 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
216 $raw = & $VallyCommand @vallyArgs 2>&1
217 $exitCode = $LASTEXITCODE
218 }
219 finally {
220 [Console]::OutputEncoding = $prev
221 $sw.Stop()
222 }
223
224 $lines = @($raw | ForEach-Object { $_.ToString() })
225 foreach ($line in $lines) { Write-Host $line }
226
227 if ($LogPath) {
228 $dir = Split-Path -Parent $LogPath
229 if ($dir -and -not (Test-Path -LiteralPath $dir)) {
230 New-Item -ItemType Directory -Path $dir -Force | Out-Null
231 }
232 Set-Content -LiteralPath $LogPath -Value $lines -Encoding utf8NoBOM
233 }
234
235 $runDir = Resolve-VallyRunDir -OutputDir $OutputDir
236 $aggregate = Read-VallyResultsJsonl -RunDir $runDir
237
238 $durationMs = if ($aggregate.durationMs -gt 0) {
239 [int]$aggregate.durationMs
240 }
241 else {
242 [int]$sw.ElapsedMilliseconds
243 }
244
245 return @{
246 specPath = $SpecPath
247 exitCode = $exitCode
248 runDir = $runDir
249 assertionsPassed = $aggregate.assertionsPassed
250 assertionsFailed = $aggregate.assertionsFailed
251 durationMs = $durationMs
252 trials = $aggregate.trials
253 resultsPath = $aggregate.resultsPath
254 perStimulus = $aggregate.perStimulus
255 }
256}
257
258function Test-SpecInputModeration {
259 <#
260 .SYNOPSIS
261 Moderates all stimulus prompts in an eval spec before execution.
262
263 .DESCRIPTION
264 Parses the eval spec YAML, extracts all stimulus.prompt fields, sends them
265 through Invoke-ContentModeration.ps1, and returns a moderation result that
266 indicates whether the spec should be skipped due to flagged input.
267
268 .PARAMETER SpecPath
269 Path to the eval spec YAML file.
270
271 .PARAMETER ArtifactId
272 Artifact identifier for scope tagging (e.g., "agent-name").
273
274 .PARAMETER ModerationScript
275 Path to Invoke-ContentModeration.ps1. Defaults to scripts/evals/Invoke-ContentModeration.ps1.
276
277 .PARAMETER Threshold
278 Toxicity threshold (0.0-1.0). Defaults to 0.5.
279
280 .PARAMETER RepoRoot
281 Repository root. Defaults to git root.
282
283 .OUTPUTS
284 [hashtable] @{ flagged = $bool; flaggedCount = $int; outputPath = $string }
285 #>
286 [CmdletBinding()]
287 [OutputType([hashtable])]
288 param(
289 [Parameter(Mandatory = $true)][string]$SpecPath,
290 [Parameter(Mandatory = $true)][string]$ArtifactId,
291 [string]$ModerationScript,
292 [double]$Threshold = 0.5,
293 [string]$RepoRoot
294 )
295
296 if (-not $RepoRoot) {
297 $RepoRoot = git rev-parse --show-toplevel 2>$null
298 if (-not $RepoRoot) { $RepoRoot = Join-Path $PSScriptRoot '../../..' }
299 }
300 if (-not $ModerationScript) {
301 $ModerationScript = Join-Path $RepoRoot 'scripts/evals/Invoke-ContentModeration.ps1'
302 }
303
304 if (-not (Test-Path -LiteralPath $SpecPath -PathType Leaf)) {
305 Write-Warning "Spec file not found: $SpecPath"
306 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
307 }
308
309 $specContent = Get-Content -LiteralPath $SpecPath -Raw -Encoding utf8
310 try {
311 $spec = $specContent | ConvertFrom-Yaml
312 }
313 catch {
314 Write-Warning "Failed to parse spec YAML: $SpecPath"
315 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
316 }
317
318 $records = @()
319 $index = 0
320 if ($spec.PSObject.Properties['stimuli'] -and $spec.stimuli) {
321 foreach ($stimulus in $spec.stimuli) {
322 if ($stimulus.PSObject.Properties['prompt'] -and $stimulus.prompt) {
323 $records += @{
324 id = "input-$ArtifactId-$index"
325 text = [string]$stimulus.prompt
326 }
327 $index++
328 }
329 }
330 }
331
332 if ($records.Count -eq 0) {
333 Write-Verbose "No stimulus prompts to moderate in $SpecPath"
334 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
335 }
336
337 $scope = "input-$ArtifactId"
338 $outFile = Join-Path $RepoRoot "logs/moderation-$scope.json"
339
340 Write-Verbose "Moderating $($records.Count) stimulus prompts for artifact: $ArtifactId"
341 try {
342 & $ModerationScript -Records $records -Scope $scope -Threshold $Threshold -OutFile $outFile -ErrorAction Stop
343 $moderationExitCode = $LASTEXITCODE
344 }
345 catch {
346 Write-Warning "Content moderation script failed: $_"
347 return @{ flagged = $true; flaggedCount = $records.Count; outputPath = $outFile }
348 }
349
350 $flagged = $moderationExitCode -ne 0
351 $flaggedCount = 0
352 if (Test-Path -LiteralPath $outFile) {
353 $output = Get-Content -LiteralPath $outFile -Raw | ConvertFrom-Json
354 $flaggedCount = [int]$output.summary.flaggedCount
355 }
356
357 return @{
358 flagged = $flagged
359 flaggedCount = $flaggedCount
360 outputPath = $outFile
361 }
362}
363
364function Test-SpecOutputModeration {
365 <#
366 .SYNOPSIS
367 Moderates model outputs from a vally eval results.jsonl file.
368
369 .DESCRIPTION
370 Reads the results.jsonl from a vally run directory, extracts all trajectory
371 model outputs, sends them through Invoke-ContentModeration.ps1, and returns
372 a moderation result indicating whether the spec outputs should be flagged.
373
374 .PARAMETER RunDir
375 Vally run directory (timestamped subdirectory under --output-dir).
376
377 .PARAMETER ArtifactId
378 Artifact identifier for scope tagging.
379
380 .PARAMETER ModerationScript
381 Path to Invoke-ContentModeration.ps1.
382
383 .PARAMETER Threshold
384 Toxicity threshold (0.0-1.0). Defaults to 0.5.
385
386 .PARAMETER RepoRoot
387 Repository root.
388
389 .OUTPUTS
390 [hashtable] @{ flagged = $bool; flaggedCount = $int; outputPath = $string }
391 #>
392 [CmdletBinding()]
393 [OutputType([hashtable])]
394 param(
395 [Parameter(Mandatory = $true)][string]$RunDir,
396 [Parameter(Mandatory = $true)][string]$ArtifactId,
397 [string]$ModerationScript,
398 [double]$Threshold = 0.5,
399 [string]$RepoRoot
400 )
401
402 if (-not $RepoRoot) {
403 $RepoRoot = git rev-parse --show-toplevel 2>$null
404 if (-not $RepoRoot) { $RepoRoot = Join-Path $PSScriptRoot '../../..' }
405 }
406 if (-not $ModerationScript) {
407 $ModerationScript = Join-Path $RepoRoot 'scripts/evals/Invoke-ContentModeration.ps1'
408 }
409
410 if ([string]::IsNullOrWhiteSpace($RunDir) -or -not (Test-Path -LiteralPath $RunDir -PathType Container)) {
411 Write-Warning "Run directory not found: $RunDir"
412 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
413 }
414
415 $jsonl = Get-ChildItem -LiteralPath $RunDir -Filter 'results.jsonl' -Recurse -File -ErrorAction SilentlyContinue |
416 Select-Object -First 1
417 if (-not $jsonl) {
418 Write-Warning "results.jsonl not found in $RunDir"
419 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
420 }
421
422 $records = @()
423 $index = 0
424 foreach ($line in Get-Content -LiteralPath $jsonl.FullName -Encoding utf8) {
425 if ([string]::IsNullOrWhiteSpace($line)) { continue }
426 try {
427 $obj = $line | ConvertFrom-Json -Depth 100
428 }
429 catch {
430 continue
431 }
432
433 $outputText = $null
434 if ($obj.PSObject.Properties['trajectory'] -and $obj.trajectory -and
435 $obj.trajectory.PSObject.Properties['output'] -and $obj.trajectory.output) {
436 $outputText = [string]$obj.trajectory.output
437 }
438
439 if ($outputText) {
440 $records += @{
441 id = "output-$ArtifactId-$index"
442 text = $outputText
443 }
444 $index++
445 }
446 }
447
448 if ($records.Count -eq 0) {
449 Write-Verbose "No model outputs to moderate from $($jsonl.FullName)"
450 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
451 }
452
453 $scope = "output-$ArtifactId"
454 $outFile = Join-Path $RepoRoot "logs/moderation-$scope.json"
455
456 Write-Verbose "Moderating $($records.Count) model outputs for artifact: $ArtifactId"
457 try {
458 & $ModerationScript -Records $records -Scope $scope -Threshold $Threshold -OutFile $outFile -ErrorAction Stop
459 $moderationExitCode = $LASTEXITCODE
460 }
461 catch {
462 Write-Warning "Content moderation script failed: $_"
463 return @{ flagged = $true; flaggedCount = $records.Count; outputPath = $outFile }
464 }
465
466 $flagged = $moderationExitCode -ne 0
467 $flaggedCount = 0
468 if (Test-Path -LiteralPath $outFile) {
469 $output = Get-Content -LiteralPath $outFile -Raw | ConvertFrom-Json
470 $flaggedCount = [int]$output.summary.flaggedCount
471 }
472
473 return @{
474 flagged = $flagged
475 flaggedCount = $flaggedCount
476 outputPath = $outFile
477 }
478}
479
480Export-ModuleMember -Function @(
481 'Resolve-VallyRunDir',
482 'Read-VallyResultsJsonl',
483 'Invoke-VallySpec',
484 'Test-SpecInputModeration',
485 'Test-SpecOutputModeration'
486)
487