AgentEvalCorpus.kt
src/jvmMain/kotlin/afield/ai/agent/AgentEvalCorpus.kt · 438 lines
package afield.ai.agent
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import org.yaml.snakeyaml.Yaml
import java.io.File
The agent eval corpus: tasks mined from the operation transcripts under dev_docs/agents/transcripts/
rather than written by hand.
InFieldAgentEvals is seventeen tasks, so a pass rate over it moves 6% per task. The transcripts hold about a thousand boxes that someone wrote and ran as real work, so each runnable one is a task:
- goal: make a box of this name, under these parents, that does what the box's own leading comment says, and whose output includes what the real box printed or displayed;
- setup: the box's ancestors in its transcript, created with their code and run in order;
- solution: the box's real code (
NewBoxRequest+RunBoxRequest/LaunchBoxRequest), so every task carries a known-good answer to measure an agent against; - checks: derived from what the solution said when run, twice: only output that came out the same both times becomes a check.
Mining is two stages. mine is static, YAML only, and rejects a box with a reason (never executed by its
transcript, needs a renderer / audio / the clock / files, prints nothing). Stage two runs each surviving
candidate's solution twice in a live Field and hands the pair to calibrate, which either accepts it as a
CorpusTask or rejects it (solution-fails, no-stable-output). The calibrated corpus is checked in as
CHECKOUT_PATH, so an eval run does not re-mine.
The split is fixed by the task id (splitOf): about a third of the tasks are heldout, never shown to
AgentLessons.distil when the protocol is followed (distil from a train run, measure on heldout).
object AgentEvalCorpus
Mining, calibration, and the checked-in form of the corpus.
object AgentEvalCorpus {
const val CHECKOUT_PATH = "src/jvmMain/resources/ai4/agent_eval_corpus.json"
const val TRANSCRIPTS_PATH = "dev_docs/agents/transcripts"
const val MAX_LIVE_SAMPLE = 25
A live corpus run may sample at most this many tasks (the hand suite's cap is AgentEvalRunner.MAX_LIVE_TASKS).
val SEARCH_TOOLS
The tools that read the repository, where every mined solution sits: withheld by a sealed run.
val SEARCH_TOOLS = listOf("WorkspaceSearchRequest", "WorkspaceSymbolSearch", "SemanticRetrievalSearchRequest", "TutorialCodeSearchRequest")
val SEALED_TOOLS
What a sealed run withholds: the search tools, and the two that read arbitrary files from outside a box
(EvaluateKotlinExpressionRequest evaluates in the server JVM; CodeUseQueryRequest returns source). The
10-26 sealed arms still saw their solution through these in 1 and 2 of 20 tasks. Box code can read files too,
so withholding is not a proof: leak checks every result afterwards, and a sealed pass that saw its solution
is scored leaked, not pass.
val SEALED_TOOLS = SEARCH_TOOLS + listOf("EvaluateKotlinExpressionRequest", "CodeUseQueryRequest")
Calls whose results echo the agent's own edits, so a solution line in them is not evidence of a lookup.
private val ECHOING_TOOLS = setOf("NewBoxRequest", "SetBoxPropertyRequest", "GetBoxPropertyRequest", "PatchBoxTextRequest")
fun fingerprint(solutionScript: String): List<String>
The solution's most distinctive code lines: the five longest non-comment lines over 25 characters.
fun fingerprint(solutionScript: String): List<String> {
val code = runCatching {
(Json.parseToJsonElement(solutionScript) as JsonArray).firstNotNullOfOrNull { step ->
((step as? JsonObject)?.get("arguments") as? JsonObject)?.get("code")?.let { (it as? JsonPrimitive)?.contentOrNull }
}
}.getOrNull().orEmpty()
return code.lines().map { it.trim() }
.filter { it.length > 25 && !it.startsWith("/*") && !it.startsWith("*") && !it.startsWith("//") }
.distinct().sortedByDescending { it.length }.take(5)
}
fun leak(solutionScript: String, result: AgentTaskResult): String?
The tool whose result held a line of the task's own solution, or null: did the turn look the answer up?
fun leak(solutionScript: String, result: AgentTaskResult): String? {
val lines = fingerprint(solutionScript).ifEmpty { return null }
return result.toolCalls.firstOrNull { call ->
call.tool !in ECHOING_TOOLS && lines.any { l -> call.result.contains(l) || call.result.replace("\\\"", "\"").contains(l) }
}?.tool
}
val DENY: List<Pair<String, Regex>>
Code that needs something a headless scratch document does not give it, or whose output cannot be the same twice. Matched as a regex anywhere in the box's code or its ancestors'.
val DENY: List<Pair<String, Regex>> = listOf(
"renderer" to Regex("""\brenderer\b|Renderer\b|moduleHere|computeModuleHere|RenderToTexture|\bMesh\b|ComputeMesh|Vulkan|frames\s*\{"""),
"shader" to Regex("""\bshader\b|fragment\s*=|vertex\s*=|#version"""),
"audio" to Regex("""Audio|Synth|\bsignal\(|Dough|SonicPi|\bsound\("""),
"python" to Regex("""(?i)\bpython\b|\bjep\b"""),
"network" to Regex("""https?://|Socket|\bmqtt\b|(?i)websocket"""),
"files" to Regex("""File\(|readText\(|readBytes\(|Paths\.get|\.toPath\(\)"""),
"camera" to Regex("""(?i)\bwebcam\b|\bcamera\b"""),
"neural" to Regex("""(?i)\bonnx\b|\bllm\b|\bai4\b"""),
"clock" to Regex("""System\.nanoTime|currentTimeMillis|Instant\.now|LocalDateTime\.now|\bnow\(\)"""),
"unseeded-random" to Regex("""Random\(\)|Math\.random|\brandom\(\)"""),
"threads" to Regex("""Thread\b|\bsleep\(|delay\("""),
// A box that runs the eval harness itself would make the corpus mine its own demo.
"self" to Regex("""AgentEvalCorpus|InFieldAgentEvals|AgentTaskRunner|AgentEvalRunner"""),
"forever" to Regex("""\bloop\s*\{|\bstart\s*\{|\bforever\s*\{|\bwhile\s*\(\s*true\s*\)|\}\s*while\s*\(\s*true\s*\)|\bfor\s*\(\s*;\s*;\s*\)|generateSequence\s*\{[^}]*\}\s*\.\s*(forEach|toList|count|last)\b"""),
)
private val OUTPUT_CALL = Regex("""\bprintln\(|\bprint\(|\bdisplay\(|\blines\(|\btable\(|\bhtml\(|\bmarkdown\(""")
const val CALIBRATION_TIMEOUT_SECONDS = 20.0
How long a calibration run of a solution may take. Runaway code the deny list cannot see (recursion with no
base case, a loop whose exit never comes) ends here as a timeout rejection, or — once the Runloop watchdog
interrupts box code — as interrupted, rather than as a hung calibration.
const val MAX_CODE_CHARS = 6000
data class Candidate
@Serializable
data class Candidate(
val id: String,
val source: String,
val box: String,
val title: String,
val code: String,
/** `run` or `launch`, as the transcript executed it. */
val verb: String,
val parents: List<String>,
/** Ancestors in transcript order: (box id, code, verb). */
val setup: List<SetupBox>,
val prose: String,
)
data class SetupBox(val box: String, val code: String, val verb: String, val parents: List<String>)
@Serializable
data class SetupBox(val box: String, val code: String, val verb: String, val parents: List<String>)
data class Rejection(val source: String, val box: String, val reason: String)
@Serializable
data class Rejection(val source: String, val box: String, val reason: String)
data class Mined(val candidates: List<Candidate>, val rejections: List<Rejection>, val transcripts: Int, val codeBoxes: Int)
@Serializable
data class Mined(val candidates: List<Candidate>, val rejections: List<Rejection>, val transcripts: Int, val codeBoxes: Int)
data class CorpusTask
@Serializable
data class CorpusTask(
val id: String,
val source: String,
val split: String,
/** `stdout` (checks on printed lines), `display` (checks on a displayed text node), `display-shape` (only a tag). */
val kind: String,
val request: AgentTaskRequest,
val solution: String,
val hasProse: Boolean,
)
data class Corpus
@Serializable
data class Corpus(
val version: Int = 1,
val minedFrom: String = TRANSCRIPTS_PATH,
val transcripts: Int = 0,
val codeBoxes: Int = 0,
val tasks: List<CorpusTask> = emptyList(),
val rejections: List<Rejection> = emptyList(),
) {
fun rejectionCounts(): Map<String, Int>
Rejection counts by reason prefix (deny:renderer, not-executed, ...).
fun rejectionCounts(): Map<String, Int> =
rejections.groupingBy { it.reason.substringBefore(" ") }.eachCount().toList().sortedByDescending { it.second }.toMap()
}
val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = false }
stage 1: static mining
fun transcriptsDir(): File = File(TRANSCRIPTS_PATH).absoluteFile
fun mine(dir: File = transcriptsDir()): Mined
fun mine(dir: File = transcriptsDir()): Mined {
val files = dir.listFiles { f -> f.isFile && f.extension in setOf("yaml", "yml") }?.sortedBy { it.name }.orEmpty()
val candidates = ArrayList<Candidate>()
val rejections = ArrayList<Rejection>()
var codeBoxes = 0
for (file in files) {
val result = runCatching { mineTranscript(file.nameWithoutExtension, file.readText()) }
.getOrElse { rejections += Rejection(file.name, "", "unparseable ${it.javaClass.simpleName}"); continue }
codeBoxes += result.codeBoxes
candidates += result.candidates
rejections += result.rejections
}
return Mined(candidates, rejections, files.size, codeBoxes)
}
fun mineTranscript(source: String, yamlText: String): Mined
body — 60 lines, from line 183
@Suppress("UNCHECKED_CAST")
fun mineTranscript(source: String, yamlText: String): Mined {
val root = Yaml().load<Any?>(yamlText) as? Map<String, Any?> ?: return Mined(emptyList(), emptyList(), 1, 0)
val ops = (root["operations"] as? List<Any?>).orEmpty().mapNotNull { it as? Map<String, Any?> }
data class Made(val box: String, val title: String, val code: String, val order: Int)
val made = LinkedHashMap<String, Made>()
val parentsOf = LinkedHashMap<String, MutableList<String>>()
val verbOf = HashMap<String, String>()
ops.forEachIndexed { index, op ->
val (kind, body) = op.entries.firstOrNull() ?: return@forEachIndexed
val args = body as? Map<String, Any?> ?: emptyMap()
val box = args["box"]?.toString().orEmpty()
when (kind) {
"create" -> {
val props = args["properties"] as? Map<String, Any?> ?: emptyMap()
val code = props["code"] as? String
if (box.isNotBlank() && code != null) made[box] = Made(box, args["name"]?.toString() ?: box, code, index)
(args["parent"] ?: args["parents"])?.let { p ->
val list = (p as? List<Any?>)?.map { it.toString() } ?: listOf(p.toString())
parentsOf.getOrPut(box) { ArrayList() } += list
}
}
"connect" -> {
val parent = args["parent"]?.toString().orEmpty()
val child = args["child"]?.toString().orEmpty()
if (parent.isNotBlank() && child.isNotBlank()) parentsOf.getOrPut(child) { ArrayList() } += parent
}
// A fragment execute runs a selection, not the box; it does not make the box a runnable whole.
"execute" -> if ((args["type"]?.toString() ?: "code") != "fragment" && box.isNotBlank()) verbOf.putIfAbsent(box, "run")
"launch" -> if (box.isNotBlank()) verbOf[box] = "launch"
}
}
val candidates = ArrayList<Candidate>()
val rejections = ArrayList<Rejection>()
fun ancestors(box: String, seen: MutableSet<String> = LinkedHashSet()): Set<String> {
for (p in parentsOf[box].orEmpty()) if (seen.add(p)) ancestors(p, seen)
return seen
}
for (m in made.values) {
fun reject(reason: String) { rejections += Rejection(source, m.box, reason) }
val verb = verbOf[m.box] ?: run { reject("not-executed"); null } ?: continue
denyReason(m.code)?.let { reject("deny:$it"); continue }
if (!OUTPUT_CALL.containsMatchIn(m.code)) { reject("no-output-call"); continue }
if (m.code.length > MAX_CODE_CHARS) { reject("too-long"); continue }
val up = ancestors(m.box)
if (m.box in up) { reject("cycle"); continue }
val missing = up.filter { it !in made }
// A parent the transcript did not create with code (a guide, the renderer context) cannot be set up here.
if (missing.isNotEmpty()) { reject("ancestor-unknown ${missing.first()}"); continue }
val ancestorDenied = up.firstNotNullOfOrNull { a -> denyReason(made[a]!!.code)?.let { "$a:$it" } }
if (ancestorDenied != null) { reject("ancestor-deny:${ancestorDenied.substringAfter(':')}"); continue }
val setup = up.map { made[it]!! }.sortedBy { it.order }
.map { SetupBox(it.box, it.code, verbOf[it.box] ?: "run", parentsOf[it.box].orEmpty().distinct()) }
candidates += Candidate(
id = "$source/${m.box}", source = source, box = m.box, title = m.title, code = m.code, verb = verb,
parents = parentsOf[m.box].orEmpty().distinct(), setup = setup, prose = prose(m.code),
)
}
return Mined(candidates, rejections, 1, made.size)
}
fun denyReason(code: String): String?
fun denyReason(code: String): String? {
val stripped = stripComments(code)
return DENY.firstOrNull { (_, re) -> re.containsMatchIn(stripped) }?.first
}
fun stripComments(code: String): String
Code without its comments: a comment that mentions the renderer should not deny a box that does not use it.
fun stripComments(code: String): String =
code.replace(Regex("""/\*[\s\S]*?\*/"""), " ").lines().joinToString("\n") { line ->
val i = line.indexOf("//")
if (i >= 0 && !line.substring(0, i).contains('"')) line.substring(0, i) else line
}
fun prose(code: String): String
The box's own description of itself: its leading comment, first two sentences, plain.
fun prose(code: String): String {
val t = code.trimStart()
val raw = when {
t.startsWith("/*") -> t.substringAfter("/*").substringBefore("*/")
t.startsWith("//") -> t.lines().takeWhile { it.trimStart().startsWith("//") }.joinToString(" ") { it.trimStart().removePrefix("//") }
else -> return ""
}
val flat = raw.lines().joinToString(" ") { it.trim().removePrefix("*").trim() }.replace(Regex("\\s+"), " ").trim()
val sentences = Regex("""(?<=[.!?])\s+""").split(flat).filter { it.isNotBlank() }
return sentences.take(2).joinToString(" ").take(400)
}
stage 2: calibration against real runs
fun splitOf(id: String): String
fun splitOf(id: String): String = if (Math.floorMod(id.hashCode(), 3) == 0) "heldout" else "train"
fun solution(c: Candidate, timeout: Double = 90.0): String
The scripted solution: the box made with its real code under its parents, then run as the transcript did.
fun solution(c: Candidate, timeout: Double = 90.0): String {
val first = c.parents.firstOrNull().orEmpty()
val calls = ArrayList<Any>()
calls += InFieldAgentEvals.call("NewBoxRequest", "name" to c.box, "code" to c.code, "parent" to first)
c.parents.drop(1).forEach { calls += InFieldAgentEvals.call("ConnectBoxesRequest", "parent" to it, "child" to c.box) }
calls += runCall(c.box, c.verb, timeout)
calls += "Made ${c.box} from its transcript code and ran it."
return InFieldAgentEvals.steps(*calls.toTypedArray())
}
private fun runCall(box: String, verb: String, timeout: Double = 90.0) =
if (verb == "launch") InFieldAgentEvals.call("LaunchBoxRequest", "box" to box, "timeoutSeconds" to timeout, "settleSeconds" to 1.0)
else InFieldAgentEvals.call("RunBoxRequest", "box" to box, "timeoutSeconds" to timeout)
fun setup(c: Candidate): String
fun setup(c: Candidate): String {
if (c.setup.isEmpty()) return ""
val calls = ArrayList<Any>()
for (s in c.setup) {
calls += InFieldAgentEvals.call("NewBoxRequest", "name" to s.box, "code" to s.code, "parent" to s.parents.firstOrNull().orEmpty())
s.parents.drop(1).forEach { calls += InFieldAgentEvals.call("ConnectBoxesRequest", "parent" to it, "child" to s.box) }
calls += runCall(s.box, s.verb)
}
return InFieldAgentEvals.steps(*calls.toTypedArray())
}
fun calibrationRequest(c: Candidate)
A calibration request: setup and solution, no checks (verdict unchecked); the tool log carries the output.
fun calibrationRequest(c: Candidate) = AgentTaskRequest(
goal = "calibrate ${c.id}", setup = setup(c), mode = "scripted", script = solution(c, CALIBRATION_TIMEOUT_SECONDS), taskId = c.id, maxToolRounds = 8,
)
data class Observed(val success: Boolean, val stage: String, val stdout: List<String>, val displays: Map<String, String>, val error: String)
What one run of the target box said, read from its Run/Launch tool record.
data class Observed(val success: Boolean, val stage: String, val stdout: List<String>, val displays: Map<String, String>, val error: String)
fun observed(result: AgentTaskResult, box: String): Observed?
fun observed(result: AgentTaskResult, box: String): Observed? {
val record = result.toolCalls.lastOrNull { (it.tool == "RunBoxRequest" || it.tool == "LaunchBoxRequest") && it.arguments.contains(box) }
?: return null
val obj = runCatching { Json.parseToJsonElement(record.result) as? JsonObject }.getOrNull() ?: return Observed(record.ok, "", emptyList(), emptyMap(), record.result.take(300))
fun str(key: String) = (obj[key] as? JsonPrimitive)?.contentOrNull.orEmpty()
val stdout = (obj["stdout"] as? JsonArray)?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }.orEmpty()
val displays = (obj["displays"] as? JsonObject)?.mapValues { (it.value as? JsonPrimitive)?.contentOrNull.orEmpty() }.orEmpty()
val success = (obj["success"] as? JsonPrimitive)?.contentOrNull == "true"
return Observed(success, str("stage"), stdout, displays, str("error"))
}
fun failureCategory(o: Observed): String?
A failed calibration run that ran away rather than failed: timeout (no verdict in time) or interrupted (the watchdog).
fun failureCategory(o: Observed): String? = when {
o.stage == "timeout" -> "timeout"
o.error.contains("Box interrupted") || o.error.contains("BoxInterruptedError") || o.stage == "interrupted" -> "interrupted"
else -> null
}
private val CHECKABLE_LINE = Regex("""[A-Za-z]""")
Output that is the same twice on one machine and different on the next: an absolute path (the first live run
found rig /Users/<me>/<worktree>/... as a check), a uid, a hash code. Never a check.
private val MACHINE_SPECIFIC = Regex("""(^|[\s'"(=:])(/(Users|home|tmp|private|var|opt)/|[A-Za-z]:\\)|[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-|@[0-9a-f]{6,}\b""")
fun checkable(line: String)
fun checkable(line: String) = CHECKABLE_LINE.containsMatchIn(line) && !MACHINE_SPECIFIC.containsMatchIn(line)
fun stableLines(a: List<String>, b: List<String>): List<String>
Lines that came out both times, in first-run order, trimmed; long ones cut to 100 chars.
fun stableLines(a: List<String>, b: List<String>): List<String> {
fun split(xs: List<String>) = xs.flatMap { it.lines() }.map { it.trim() }.filter { it.length >= 3 }
val second = split(b).toSet()
return split(a).filter { it in second }.distinct()
}
fun stableTextNodes(a: Map<String, String>, b: Map<String, String>): List<String>
Text nodes (between tags) that appeared in both displays.
fun stableTextNodes(a: Map<String, String>, b: Map<String, String>): List<String> {
fun nodes(m: Map<String, String>) = m.values.flatMap { html ->
Regex(""">([^<>]{4,200})<""").findAll(html).map { it.groupValues[1].trim() }.filter { it.length >= 4 && checkable(it) }.toList()
}
val second = nodes(b).toSet()
return nodes(a).filter { it in second }.distinct()
}
sealed interface Calibrated
data class Accepted(val task: CorpusTask) : Calibrated
data class Refused(val rejection: Rejection) : Calibrated
fun calibrate(c: Candidate, first: AgentTaskResult, second: AgentTaskResult): Calibrated
Turn two scripted runs of c's solution into a task: checks on the output that was the same both times.
Up to two printed lines (first and last checkable), else one displayed text node, else — weakest — the
first tag of the display.
fun calibrate(c: Candidate, first: AgentTaskResult, second: AgentTaskResult): Calibrated {
fun refuse(reason: String) = Refused(Rejection(c.source, c.box, reason))
val a = observed(first, c.box) ?: return refuse("no-run-record ${first.error.take(120)}")
val b = observed(second, c.box) ?: return refuse("no-run-record ${second.error.take(120)}")
if (!a.success || !b.success) {
val bad = if (!a.success) a else b
failureCategory(bad)?.let { return refuse("$it ${bad.error.lines().firstOrNull().orEmpty().take(160)}".trim()) }
return refuse("solution-fails ${bad.stage.ifBlank { "?" }}: ${bad.error.lines().firstOrNull().orEmpty().take(160)}")
}
val base = mutableListOf(AgentCheck.parse("box-exists:${c.box}"), AgentCheck.parse("last-run-ok:${c.box}"))
c.parents.forEach { base += AgentCheck.parse("connected:$it:${c.box}") }
val lines = stableLines(a.stdout, b.stdout).filter(::checkable)
val texts = stableTextNodes(a.displays, b.displays)
val (kind, expect) = when {
lines.isNotEmpty() -> "stdout" to listOf(lines.first(), lines.last()).distinct().map { it.take(100) }
texts.isNotEmpty() -> "display" to listOf(texts.first().take(100))
a.displays.isNotEmpty() && a.displays.keys == b.displays.keys -> {
val tag = Regex("""<([a-zA-Z][a-zA-Z0-9]*)""").find(a.displays.values.first())?.groupValues?.get(1)
?: return refuse("no-stable-output display without a tag")
"display-shape" to listOf("<$tag")
}
a.stdout.isNotEmpty() || a.displays.isNotEmpty() -> return refuse("no-stable-output differs between runs")
else -> return refuse("no-stable-output said nothing")
}
val checks = base + expect.map { AgentCheck(kind = "output-contains", box = c.box, text = it) }
val goal = goal(c, kind, expect)
return Accepted(CorpusTask(
id = c.id, source = c.source, split = splitOf(c.id), kind = kind,
request = AgentTaskRequest(goal = goal, checks = checks, setup = setup(c), taskId = c.id),
solution = solution(c), hasProse = c.prose.isNotBlank(),
))
}
fun goal(c: Candidate, kind: String, expect: List<String>): String
fun goal(c: Candidate, kind: String, expect: List<String>): String = buildString {
val verb = if (c.verb == "launch") "launch" else "run"
append("Make a box named ${c.box}")
if (c.title != c.box) append(" (titled \"${c.title}\")")
if (c.parents.isNotEmpty()) append(" as a child of ${c.parents.joinToString(" and ")}, which already exist and have been run; it may use what they share")
append(", and $verb it.")
if (c.prose.isNotBlank()) append(" What it is for, in its author's words: ${c.prose}")
append(when (kind) {
"stdout" -> " When it runs, what it prints must include " + expect.joinToString(" and ") { "the text «$it»" } + "."
"display" -> " It must display (with display(...) or a drawing helper) output that includes the text «${expect.first()}»."
else -> " It must display output (with display(...) or a drawing helper such as lines(...)) containing a ${expect.first()}> element."
})
}
the checked-in corpus
fun sourceFile(): File? = File(CHECKOUT_PATH).absoluteFile.takeIf { it.isFile }
fun load(): Corpus
fun load(): Corpus {
val text = sourceFile()?.readText()
?: AgentEvalCorpus::class.java.getResource("/ai4/agent_eval_corpus.json")?.readText()
?: return Corpus()
return json.decodeFromString(Corpus.serializer(), text)
}
fun write(corpus: Corpus, target: File = File(CHECKOUT_PATH).absoluteFile)
fun write(corpus: Corpus, target: File = File(CHECKOUT_PATH).absoluteFile) {
target.parentFile?.mkdirs()
target.writeText(json.encodeToString(Corpus.serializer(), corpus) + "\n")
}
fun select(corpus: Corpus, split: String, sample: Int, seed: Long, ids: List<String> = emptyList()): List<CorpusTask>
The tasks a run asks for: split all / train / heldout, then sample of them (0 = all) drawn by seed.
A seeded shuffle, so --sample 20 --seed 1 names the same tasks on every machine.
fun select(corpus: Corpus, split: String, sample: Int, seed: Long, ids: List<String> = emptyList()): List<CorpusTask> {
var pool = when (split) {
"", "all" -> corpus.tasks
"train", "heldout" -> corpus.tasks.filter { it.split == split }
else -> throw IllegalArgumentException("corpus split must be all, train or heldout, not '$split'")
}
if (ids.isNotEmpty()) pool = pool.filter { it.id in ids }
if (sample <= 0 || sample >= pool.size) return pool
return pool.sortedBy { it.id }.shuffled(java.util.Random(seed)).take(sample)
}
}