microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
feat/1637-l4-tests

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/evals/Modules/VallyRunner.psm1

574lines · 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 Get-VallySpecThreshold {
50 <#
51 .SYNOPSIS
52 Reads an eval spec's scoring.threshold value when available.
53
54 .DESCRIPTION
55 Some evals report trial success through `gradeResult.score` rather than a
56 hard `gradeResult.passed` boolean. When the spec contains
57 `scoring.threshold`, the runner uses that threshold to interpret those
58 scores.
59
60 .PARAMETER SpecPath
61 Path to the eval spec YAML file.
62
63 .OUTPUTS
64 [double] The configured threshold, or $null when absent.
65 #>
66 [CmdletBinding()]
67 [OutputType([double])]
68 param(
69 [Parameter(Mandatory = $true)]
70 [string]$SpecPath
71 )
72
73 if ([string]::IsNullOrWhiteSpace($SpecPath) -or -not (Test-Path -LiteralPath $SpecPath -PathType Leaf)) {
74 return $null
75 }
76
77 if (-not (Get-Module -ListAvailable -Name 'powershell-yaml')) {
78 return $null
79 }
80
81 try {
82 Import-Module powershell-yaml -ErrorAction Stop | Out-Null
83 }
84 catch {
85 return $null
86 }
87
88 try {
89 $spec = Get-Content -LiteralPath $SpecPath -Raw -Encoding utf8 | ConvertFrom-Yaml
90 }
91 catch {
92 return $null
93 }
94
95 if ($null -eq $spec) { return $null }
96
97 if ($spec -is [System.Collections.IDictionary]) {
98 if ($spec.Contains('scoring')) {
99 $scoring = $spec['scoring']
100 if ($scoring -is [System.Collections.IDictionary] -and $scoring.Contains('threshold')) {
101 return [double]$scoring['threshold']
102 }
103 }
104 return $null
105 }
106
107 $scoring = $spec.PSObject.Properties['scoring']
108 if ($null -eq $scoring -or $null -eq $scoring.Value) { return $null }
109
110 $threshold = $scoring.Value.PSObject.Properties['threshold']
111 if ($null -eq $threshold -or $null -eq $threshold.Value) { return $null }
112
113 return [double]$threshold.Value
114}
115
116function Read-VallyResultsJsonl {
117 <#
118 .SYNOPSIS
119 Aggregates trial outcomes from a vally `results.jsonl` file.
120
121 .DESCRIPTION
122 Reads the `results.jsonl` written by `vally eval` (located under the run
123 directory returned by `Resolve-VallyRunDir`) and tallies passing/failing
124 trials plus aggregate wall time. Malformed lines are skipped rather than
125 thrown so a partial run still yields counts.
126
127 .PARAMETER RunDir
128 Directory returned by `Resolve-VallyRunDir`.
129
130 .OUTPUTS
131 [hashtable] `@{ assertionsPassed; assertionsFailed; durationMs; trials; resultsPath; perStimulus }`.
132 `perStimulus` is an ordered map keyed by stimulus name with `@{ assertionsPassed; assertionsFailed; durationMs; trials }`.
133 #>
134 [CmdletBinding()]
135 [OutputType([hashtable])]
136 param(
137 [Parameter(Mandatory = $true)]
138 [AllowNull()]
139 [AllowEmptyString()]
140 [string]$RunDir,
141 [Nullable[double]]$Threshold
142 )
143
144 $empty = @{
145 assertionsPassed = 0
146 assertionsFailed = 0
147 durationMs = 0
148 trials = 0
149 resultsPath = $null
150 perStimulus = [ordered]@{}
151 }
152
153 if ([string]::IsNullOrWhiteSpace($RunDir) -or -not (Test-Path -LiteralPath $RunDir -PathType Container)) {
154 return $empty
155 }
156
157 $jsonl = Get-ChildItem -LiteralPath $RunDir -Filter 'results.jsonl' -Recurse -File -ErrorAction SilentlyContinue |
158 Select-Object -First 1
159 if (-not $jsonl) { return $empty }
160
161 $passed = 0
162 $failed = 0
163 $durationMs = 0
164 $trials = 0
165 $perStimulus = [ordered]@{}
166
167 foreach ($line in Get-Content -LiteralPath $jsonl.FullName -Encoding utf8) {
168 if ([string]::IsNullOrWhiteSpace($line)) { continue }
169 try {
170 $obj = $line | ConvertFrom-Json -Depth 100
171 }
172 catch {
173 continue
174 }
175
176 $trials++
177
178 $trialPassed = $false
179 $gradeResult = $null
180 if ($obj.PSObject.Properties['gradeResult']) {
181 $gradeResult = $obj.gradeResult
182 }
183 $hasScore = $false
184 $scoreValue = $null
185 if ($gradeResult -and $gradeResult.PSObject.Properties['score'] -and $null -ne $gradeResult.score) {
186 $hasScore = $true
187 $scoreValue = [double]$gradeResult.score
188 }
189
190 if ($hasScore -and $PSBoundParameters.ContainsKey('Threshold') -and $null -ne $Threshold) {
191 $trialPassed = $scoreValue -ge [double]$Threshold
192 }
193 elseif ($gradeResult -and $gradeResult.PSObject.Properties['passed'] -and $null -ne $gradeResult.passed) {
194 $trialPassed = [bool]$gradeResult.passed
195 }
196 if ($trialPassed) { $passed++ } else { $failed++ }
197
198 $trialWallMs = 0
199 if ($obj.PSObject.Properties['trajectory'] -and $obj.trajectory -and
200 $obj.trajectory.PSObject.Properties['metrics'] -and $obj.trajectory.metrics -and
201 $obj.trajectory.metrics.PSObject.Properties['wallTimeMs'] -and
202 $null -ne $obj.trajectory.metrics.wallTimeMs) {
203 $trialWallMs = [int]$obj.trajectory.metrics.wallTimeMs
204 $durationMs += $trialWallMs
205 }
206
207 $stimulusName = $null
208 if ($obj.PSObject.Properties['trajectory'] -and $obj.trajectory -and
209 $obj.trajectory.PSObject.Properties['stimulus'] -and $obj.trajectory.stimulus -and
210 $obj.trajectory.stimulus.PSObject.Properties['name'] -and
211 -not [string]::IsNullOrWhiteSpace([string]$obj.trajectory.stimulus.name)) {
212 $stimulusName = [string]$obj.trajectory.stimulus.name
213 }
214
215 if ($stimulusName) {
216 if (-not $perStimulus.Contains($stimulusName)) {
217 $perStimulus[$stimulusName] = @{
218 assertionsPassed = 0
219 assertionsFailed = 0
220 durationMs = 0
221 trials = 0
222 }
223 }
224 $bucket = $perStimulus[$stimulusName]
225 $bucket.trials++
226 if ($trialPassed) { $bucket.assertionsPassed++ } else { $bucket.assertionsFailed++ }
227 $bucket.durationMs += $trialWallMs
228 }
229 }
230
231 return @{
232 assertionsPassed = $passed
233 assertionsFailed = $failed
234 durationMs = $durationMs
235 trials = $trials
236 resultsPath = $jsonl.FullName
237 perStimulus = $perStimulus
238 }
239}
240
241function Invoke-VallySpec {
242 <#
243 .SYNOPSIS
244 Runs `vally eval` for a single spec and returns aggregated outcomes.
245
246 .DESCRIPTION
247 Invokes the configured vally executable with `eval --eval-spec --model
248 --output-dir`, captures stdout/stderr (optionally tee'd to a log file),
249 resolves the timestamped run directory under `OutputDir`, and aggregates
250 the `results.jsonl` via `Read-VallyResultsJsonl`.
251
252 .PARAMETER SpecPath
253 Path to the eval spec YAML file.
254
255 .PARAMETER OutputDir
256 Directory passed to `vally eval --output-dir`. Created if it does not exist.
257
258 .PARAMETER Model
259 Model passed to `vally eval --model`.
260
261 .PARAMETER VallyCommand
262 Path or name of the vally executable. Defaults to `vally`. Tests override
263 this with the stub fixture path.
264
265 .PARAMETER LogPath
266 Optional path to tee stdout/stderr to a log file.
267
268 .OUTPUTS
269 [hashtable] `@{ specPath; exitCode; runDir; assertionsPassed; assertionsFailed; durationMs; trials; resultsPath; perStimulus }`.
270 #>
271 [CmdletBinding()]
272 [OutputType([hashtable])]
273 param(
274 [Parameter(Mandatory = $true)][string]$SpecPath,
275 [Parameter(Mandatory = $true)][string]$OutputDir,
276 [Parameter(Mandatory = $true)][string]$Model,
277 [string]$VallyCommand = 'vally',
278 [string]$LogPath
279 )
280
281 if (-not (Test-Path -LiteralPath $OutputDir)) {
282 New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
283 }
284
285 $vallyArgs = @(
286 'eval'
287 '--eval-spec', $SpecPath
288 '--model', $Model
289 '--output-dir', $OutputDir
290 )
291
292 $sw = [System.Diagnostics.Stopwatch]::StartNew()
293 $prev = [Console]::OutputEncoding
294 $exitCode = 0
295 try {
296 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
297 $raw = & $VallyCommand @vallyArgs 2>&1
298 $exitCode = $LASTEXITCODE
299 }
300 finally {
301 [Console]::OutputEncoding = $prev
302 $sw.Stop()
303 }
304
305 $lines = @($raw | ForEach-Object { $_.ToString() })
306 foreach ($line in $lines) { Write-Host $line }
307
308 if ($LogPath) {
309 $dir = Split-Path -Parent $LogPath
310 if ($dir -and -not (Test-Path -LiteralPath $dir)) {
311 New-Item -ItemType Directory -Path $dir -Force | Out-Null
312 }
313 Set-Content -LiteralPath $LogPath -Value $lines -Encoding utf8NoBOM
314 }
315
316 $runDir = Resolve-VallyRunDir -OutputDir $OutputDir
317 $threshold = Get-VallySpecThreshold -SpecPath $SpecPath
318 $aggregate = Read-VallyResultsJsonl -RunDir $runDir -Threshold $threshold
319
320 $durationMs = if ($aggregate.durationMs -gt 0) {
321 [int]$aggregate.durationMs
322 }
323 else {
324 [int]$sw.ElapsedMilliseconds
325 }
326
327 return @{
328 specPath = $SpecPath
329 exitCode = $exitCode
330 runDir = $runDir
331 assertionsPassed = $aggregate.assertionsPassed
332 assertionsFailed = $aggregate.assertionsFailed
333 durationMs = $durationMs
334 trials = $aggregate.trials
335 resultsPath = $aggregate.resultsPath
336 perStimulus = $aggregate.perStimulus
337 }
338}
339
340function Test-SpecInputModeration {
341 <#
342 .SYNOPSIS
343 Moderates all stimulus prompts in an eval spec before execution.
344
345 .DESCRIPTION
346 Parses the eval spec YAML, extracts all stimulus.prompt fields, sends them
347 through Invoke-ContentModeration.ps1, and returns a moderation result that
348 indicates whether the spec should be skipped due to flagged input.
349
350 .PARAMETER SpecPath
351 Path to the eval spec YAML file.
352
353 .PARAMETER ArtifactId
354 Artifact identifier for scope tagging (e.g., "agent-name").
355
356 .PARAMETER ModerationScript
357 Path to Invoke-ContentModeration.ps1. Defaults to scripts/evals/Invoke-ContentModeration.ps1.
358
359 .PARAMETER Threshold
360 Toxicity threshold (0.0-1.0). Defaults to 0.5.
361
362 .PARAMETER RepoRoot
363 Repository root. Defaults to git root.
364
365 .OUTPUTS
366 [hashtable] @{ flagged = $bool; flaggedCount = $int; outputPath = $string; error = $bool }
367 #>
368 [CmdletBinding()]
369 [OutputType([hashtable])]
370 param(
371 [Parameter(Mandatory = $true)][string]$SpecPath,
372 [Parameter(Mandatory = $true)][string]$ArtifactId,
373 [string]$ModerationScript,
374 [double]$Threshold = 0.5,
375 [string]$RepoRoot
376 )
377
378 if (-not $RepoRoot) {
379 $RepoRoot = git rev-parse --show-toplevel 2>$null
380 if (-not $RepoRoot) { $RepoRoot = Join-Path $PSScriptRoot '../../..' }
381 }
382 if (-not $ModerationScript) {
383 $ModerationScript = Join-Path $RepoRoot 'scripts/evals/Invoke-ContentModeration.ps1'
384 }
385
386 if (-not (Test-Path -LiteralPath $SpecPath -PathType Leaf)) {
387 Write-Warning "Spec file not found: $SpecPath"
388 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
389 }
390
391 $specContent = Get-Content -LiteralPath $SpecPath -Raw -Encoding utf8
392 try {
393 $spec = $specContent | ConvertFrom-Yaml
394 }
395 catch {
396 Write-Warning "Failed to parse spec YAML: $SpecPath"
397 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
398 }
399
400 $records = @()
401 $index = 0
402 if ($spec -and $spec.stimuli) {
403 foreach ($stimulus in $spec.stimuli) {
404 if ($stimulus -and $stimulus.prompt) {
405 $records += @{
406 id = "input-$ArtifactId-$index"
407 text = [string]$stimulus.prompt
408 }
409 $index++
410 }
411 }
412 }
413
414 if ($records.Count -eq 0) {
415 Write-Verbose "No stimulus prompts to moderate in $SpecPath"
416 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
417 }
418
419 $scope = "input-$ArtifactId"
420 $outFile = Join-Path $RepoRoot "logs/moderation-$scope.json"
421
422 Write-Verbose "Moderating $($records.Count) stimulus prompts for artifact: $ArtifactId"
423 try {
424 & $ModerationScript -Records $records -Scope $scope -Threshold $Threshold -OutFile $outFile -ErrorAction Stop
425 $moderationExitCode = $LASTEXITCODE
426 }
427 catch {
428 Write-Warning "Content moderation script failed: $_"
429 return @{ flagged = $false; flaggedCount = 0; outputPath = $outFile; error = $true }
430 }
431
432 # Exit 1 = genuine content flag; exit >=2 = moderation infrastructure/usage error.
433 $flagged = $moderationExitCode -eq 1
434 $moderationError = $moderationExitCode -ge 2
435 $flaggedCount = 0
436 if (Test-Path -LiteralPath $outFile) {
437 $output = Get-Content -LiteralPath $outFile -Raw | ConvertFrom-Json
438 $flaggedCount = [int]$output.summary.flaggedCount
439 }
440
441 return @{
442 flagged = $flagged
443 flaggedCount = $flaggedCount
444 outputPath = $outFile
445 error = $moderationError
446 }
447}
448
449function Test-SpecOutputModeration {
450 <#
451 .SYNOPSIS
452 Moderates model outputs from a vally eval results.jsonl file.
453
454 .DESCRIPTION
455 Reads the results.jsonl from a vally run directory, extracts all trajectory
456 model outputs, sends them through Invoke-ContentModeration.ps1, and returns
457 a moderation result indicating whether the spec outputs should be flagged.
458
459 .PARAMETER RunDir
460 Vally run directory (timestamped subdirectory under --output-dir).
461
462 .PARAMETER ArtifactId
463 Artifact identifier for scope tagging.
464
465 .PARAMETER ModerationScript
466 Path to Invoke-ContentModeration.ps1.
467
468 .PARAMETER Threshold
469 Toxicity threshold (0.0-1.0). Defaults to 0.5.
470
471 .PARAMETER RepoRoot
472 Repository root.
473
474 .OUTPUTS
475 [hashtable] @{ flagged = $bool; flaggedCount = $int; outputPath = $string; error = $bool }
476 #>
477 [CmdletBinding()]
478 [OutputType([hashtable])]
479 param(
480 [Parameter(Mandatory = $true)][string]$RunDir,
481 [Parameter(Mandatory = $true)][string]$ArtifactId,
482 [string]$ModerationScript,
483 [double]$Threshold = 0.5,
484 [string]$RepoRoot
485 )
486
487 if (-not $RepoRoot) {
488 $RepoRoot = git rev-parse --show-toplevel 2>$null
489 if (-not $RepoRoot) { $RepoRoot = Join-Path $PSScriptRoot '../../..' }
490 }
491 if (-not $ModerationScript) {
492 $ModerationScript = Join-Path $RepoRoot 'scripts/evals/Invoke-ContentModeration.ps1'
493 }
494
495 if ([string]::IsNullOrWhiteSpace($RunDir) -or -not (Test-Path -LiteralPath $RunDir -PathType Container)) {
496 Write-Warning "Run directory not found: $RunDir"
497 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
498 }
499
500 $jsonl = Get-ChildItem -LiteralPath $RunDir -Filter 'results.jsonl' -Recurse -File -ErrorAction SilentlyContinue |
501 Select-Object -First 1
502 if (-not $jsonl) {
503 Write-Warning "results.jsonl not found in $RunDir"
504 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
505 }
506
507 $records = @()
508 $index = 0
509 foreach ($line in Get-Content -LiteralPath $jsonl.FullName -Encoding utf8) {
510 if ([string]::IsNullOrWhiteSpace($line)) { continue }
511 try {
512 $obj = $line | ConvertFrom-Json -Depth 100
513 }
514 catch {
515 continue
516 }
517
518 $outputText = $null
519 if ($obj.PSObject.Properties['trajectory'] -and $obj.trajectory -and
520 $obj.trajectory.PSObject.Properties['output'] -and $obj.trajectory.output) {
521 $outputText = [string]$obj.trajectory.output
522 }
523
524 if ($outputText) {
525 $records += @{
526 id = "output-$ArtifactId-$index"
527 text = $outputText
528 }
529 $index++
530 }
531 }
532
533 if ($records.Count -eq 0) {
534 Write-Verbose "No model outputs to moderate from $($jsonl.FullName)"
535 return @{ flagged = $false; flaggedCount = 0; outputPath = $null }
536 }
537
538 $scope = "output-$ArtifactId"
539 $outFile = Join-Path $RepoRoot "logs/moderation-$scope.json"
540
541 Write-Verbose "Moderating $($records.Count) model outputs for artifact: $ArtifactId"
542 try {
543 & $ModerationScript -Records $records -Scope $scope -Threshold $Threshold -OutFile $outFile -ErrorAction Stop
544 $moderationExitCode = $LASTEXITCODE
545 }
546 catch {
547 Write-Warning "Content moderation script failed: $_"
548 return @{ flagged = $false; flaggedCount = 0; outputPath = $outFile; error = $true }
549 }
550
551 # Exit 1 = genuine content flag; exit >=2 = moderation infrastructure/usage error.
552 $flagged = $moderationExitCode -eq 1
553 $moderationError = $moderationExitCode -ge 2
554 $flaggedCount = 0
555 if (Test-Path -LiteralPath $outFile) {
556 $output = Get-Content -LiteralPath $outFile -Raw | ConvertFrom-Json
557 $flaggedCount = [int]$output.summary.flaggedCount
558 }
559
560 return @{
561 flagged = $flagged
562 flaggedCount = $flaggedCount
563 outputPath = $outFile
564 error = $moderationError
565 }
566}
567
568Export-ModuleMember -Function @(
569 'Resolve-VallyRunDir',
570 'Read-VallyResultsJsonl',
571 'Invoke-VallySpec',
572 'Test-SpecInputModeration',
573 'Test-SpecOutputModeration'
574)
575