Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions .github/workflows/ground-truth.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
name: Ground Truth Validation

on:
push:
branches: [ main, develop ]
paths:
- "skainet-test/skainet-test-groundtruth/**"
- ".github/workflows/ground-truth.yml"
pull_request:
branches: [ main, develop ]
paths:
- "skainet-test/skainet-test-groundtruth/**"
- ".github/workflows/ground-truth.yml"
# skainet-ground-truth's PyTorch fixtures can change independently of any commit here —
# a weekly run catches that drift even when nothing in this repo touched the module.
schedule:
- cron: "0 6 * * 1"
workflow_dispatch: {}

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Set default permission for all jobs to none
permissions: {}

jobs:
ground-truth:
runs-on: ubuntu-latest
timeout-minutes: 30

permissions:
contents: read

env:
# actions/checkout's `path` can't escape the primary repo's own workspace with `..`
# (hard-rejected: "Repository path ... is not under ...") — so this lands as a plain
# subdirectory of SKaiNET's checkout instead of a true sibling, and every Gradle
# invocation below points at it explicitly via -PgroundTruthSourceDir (the override
# skainet-test-groundtruth/build.gradle.kts's groundTruthProjectDir already supports —
# local dev still uses the sibling-checkout default, only CI needs this).
GROUND_TRUTH_SOURCE_DIR: ${{ github.workspace }}/skainet-ground-truth/pytorch

steps:
- name: Checkout SKaiNET
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Checkout skainet-ground-truth
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: SKaiNET-developers/skainet-ground-truth
path: skainet-ground-truth

- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties

- name: Set up JDK 21
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
with:
distribution: 'zulu'
java-version: 21

- name: Build ground truth Docker image
run: |
./gradlew --no-daemon --stacktrace --no-configuration-cache \
-PgroundTruthSourceDir="$GROUND_TRUTH_SOURCE_DIR" \
:skainet-test:skainet-test-groundtruth:buildGroundTruthDocker

- name: Generate ground truth GGUF files
run: |
./gradlew --no-daemon --stacktrace --no-configuration-cache \
-PgroundTruthSourceDir="$GROUND_TRUTH_SOURCE_DIR" \
:skainet-test:skainet-test-groundtruth:generateGroundTruth

# -PrequireGroundTruth=true turns a missing/broken pipeline into a hard failure instead
# of the tests silently skipping (see GroundTruthConfig.requireAvailable) — the whole
# point of this workflow is proving the pipeline above actually produced something.
- name: Run validation tests
env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx4g -Dfile.encoding=UTF-8"
run: |
./gradlew --no-daemon --stacktrace --no-configuration-cache \
-PgroundTruthSourceDir="$GROUND_TRUTH_SOURCE_DIR" \
:skainet-test:skainet-test-groundtruth:jvmTest -PrequireGroundTruth=true

- name: Upload test reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ground-truth-reports
path: |
skainet-test/skainet-test-groundtruth/build/reports/tests/**
skainet-test/skainet-test-groundtruth/build/test-results/**
retention-days: 14
14 changes: 12 additions & 2 deletions skainet-test/skainet-test-groundtruth/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ plugins {
// Ground Truth Generation via Docker
// =============================================================================

// Path to the skainet-ground-truth project (relative to SKaiNET root)
val groundTruthProjectDir = rootProject.projectDir.parentFile.resolve("skainet-ground-truth/pytorch")
// Path to the skainet-ground-truth project's `pytorch` subdirectory. Override with
// -PgroundTruthSourceDir=/path/to/pytorch — e.g. once fixture generation moves to a
// numcrux-hosted corpus (https://github.com/numcrux) instead of this SKaiNET-org sibling
// checkout. Defaults to today's convenience: a `skainet-ground-truth` checkout next to
// this repo (../skainet-ground-truth relative to SKaiNET's root), matching the layout
// GroundTruthConfig.findDefaultResultsDir() also assumes on the Kotlin test side.
val groundTruthProjectDir = (findProperty("groundTruthSourceDir") as String?)
?.let(::File)
?: rootProject.projectDir.parentFile.resolve("skainet-ground-truth/pytorch")
val groundTruthResultsDir = groundTruthProjectDir.resolve("results")

// Docker image name for ground truth generation
Expand Down Expand Up @@ -97,6 +104,9 @@ tasks.register("listGroundTruth") {
// Make ground truth results available as a system property for tests
tasks.withType<Test> {
systemProperty("groundtruth.results.dir", groundTruthResultsDir.absolutePath)
// -PrequireGroundTruth=true (CI) turns missing-ground-truth from a silent skip into a
// hard failure — see GroundTruthConfig.requireAvailable / TestAssumptions.kt.
systemProperty("groundtruth.require", (findProperty("requireGroundTruth") as String?) ?: "false")
}

kotlin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,55 @@ public data class GroundTruthTestCase(
val useCase: String? = null,

/** Path to the source GGUF file */
val sourcePath: String? = null
val sourcePath: String? = null,

/**
* Operation parameters as written by the Python side's `op.*` GGUF metadata keys
* (e.g. `op.padding` -> `"padding" to 1`), decoded to Int/Float/List<Int>/List<Float>
* per value. See [resolvedParams] to turn this into a typed [OperationParams].
*/
val rawOpParams: Map<String, Any> = emptyMap()
)

/**
* Maps [GroundTruthTestCase.rawOpParams] onto [OperationParams]'s named, typed fields.
* A scalar value (`op.padding = 1`) becomes a symmetric pair for the height/width-style
* params, matching how [OperationParamsBuilder.padding] already turns a single Int into
* `(value, value)` — the Python side writes symmetric params as a plain scalar, not a
* 2-element array (see `skainet-ground-truth`'s `op_params={"padding": 1, ...}` usage).
*/
public fun GroundTruthTestCase.resolvedParams(): OperationParams {
fun pair(name: String): Pair<Int, Int>? = when (val v = rawOpParams[name]) {
is Int -> v to v
is Float -> v.toInt() to v.toInt()
is List<*> -> when (v.size) {
1 -> (v[0] as Number).toInt().let { it to it }
else -> (v.getOrNull(0) as? Number)?.toInt()?.let { h ->
(v.getOrNull(1) as? Number)?.toInt()?.let { w -> h to w }
}
}
else -> null
}

fun int(name: String): Int? = (rawOpParams[name] as? Number)?.toInt()
?: (rawOpParams[name] as? List<*>)?.firstOrNull().let { (it as? Number)?.toInt() }

fun float(name: String): Float? = (rawOpParams[name] as? Number)?.toFloat()

return OperationParams(
stride = pair("stride"),
padding = pair("padding"),
dilation = pair("dilation"),
groups = int("groups"),
kernelSize = pair("kernel_size") ?: pair("kernelSize"),
dim = int("dim"),
startDim = int("start_dim") ?: int("startDim"),
endDim = int("end_dim") ?: int("endDim"),
negativeSlope = float("negative_slope") ?: float("negativeSlope"),
alpha = float("alpha")
)
}

/**
* Represents a tensor loaded from ground truth GGUF file.
* Stores the raw float data and shape information.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,16 @@ public class GroundTruthValidator(
* Validate a ground truth test case.
*
* @param testCase The test case to validate
* @param params Operation parameters (stride, padding, etc.)
* @param params Operation parameters (stride, padding, etc.) — defaults to
* [testCase]'s own `op.*` GGUF metadata ([GroundTruthTestCase.resolvedParams]),
* not an empty [OperationParams]; pass an explicit value to override.
* @param tolerance Absolute tolerance for comparison (auto-selected if null)
* @param rtol Relative tolerance for comparison
* @param validateGradients Whether to also validate gradient computation
*/
public fun validate(
testCase: GroundTruthTestCase,
params: OperationParams = OperationParams(),
params: OperationParams = testCase.resolvedParams(),
tolerance: Float? = null,
rtol: Float = 1e-5f,
validateGradients: Boolean = false
Expand Down Expand Up @@ -180,7 +182,7 @@ public class GroundTruthValidator(
*/
public fun validateAll(
testCases: List<GroundTruthTestCase>,
paramsProvider: (GroundTruthTestCase) -> OperationParams = { OperationParams() },
paramsProvider: (GroundTruthTestCase) -> OperationParams = { it.resolvedParams() },
tolerance: Float? = null,
rtol: Float = 1e-5f
): List<ValidationResult> {
Expand All @@ -194,7 +196,7 @@ public class GroundTruthValidator(
*/
public fun assertValid(
testCase: GroundTruthTestCase,
params: OperationParams = OperationParams(),
params: OperationParams = testCase.resolvedParams(),
tolerance: Float? = null,
rtol: Float = 1e-5f
) {
Expand Down Expand Up @@ -275,7 +277,7 @@ public class GroundTruthValidator(
*/
public fun GroundTruthTestCase.validateWith(
ops: TensorOps,
params: OperationParams = OperationParams(),
params: OperationParams = resolvedParams(),
tolerance: Float? = null
): GroundTruthValidator.ValidationResult {
return GroundTruthValidator(ops).validate(this, params, tolerance)
Expand All @@ -286,7 +288,7 @@ public fun GroundTruthTestCase.validateWith(
*/
public fun GroundTruthTestCase.assertValidWith(
ops: TensorOps,
params: OperationParams = OperationParams(),
params: OperationParams = resolvedParams(),
tolerance: Float? = null
) {
GroundTruthValidator(ops).assertValid(this, params, tolerance)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,16 @@ public object GroundTruthConfig {
false
}

/**
* When true, missing ground truth is a hard test failure instead of a skip — set via
* `-PrequireGroundTruth=true` (see build.gradle.kts), used in CI so a broken/missing
* pipeline shows up red instead of silently skipping every ground-truth test.
* Local dev runs default to false: skip gracefully when the sibling
* `../skainet-ground-truth` checkout or generated GGUF files aren't present.
*/
public val requireAvailable: Boolean
get() = System.getProperty("groundtruth.require")?.toBoolean() ?: false

/**
* Get all available test suites.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import kotlinx.io.buffered
import sk.ainet.io.gguf.GGMLQuantizationType
import sk.ainet.io.gguf.GGUFReader
import sk.ainet.io.gguf.GGUFValueType
import sk.ainet.io.gguf.ReaderField
import sk.ainet.io.gguf.ReaderTensor
import sk.ainet.lang.tensor.Shape
import java.io.File
Expand Down Expand Up @@ -94,10 +95,49 @@ public object GroundTruthLoader {
expectedGradients = gradients.ifEmpty { null },
testSuite = testSuite,
useCase = useCase,
sourcePath = sourcePath
sourcePath = sourcePath,
rawOpParams = extractOpParams(reader)
)
}

/**
* Extract every `op.*` GGUF metadata field into a name -> value map (name with the
* `op.` prefix stripped). Written by `store_experiment_as_gguf`'s `op_params` handling
* (`gt/pytorch/io/writer.py`): `add_int32`/`add_uint32` for a scalar int, `add_float32`
* for a scalar float, `add_array` for a list/tuple of ints (e.g. an asymmetric
* `(stride_h, stride_w)`) — see [decodeFieldValue] for the corresponding GGUF-side shape.
*/
private fun extractOpParams(reader: GGUFReader): Map<String, Any> {
val result = mutableMapOf<String, Any>()
for ((name, field) in reader.fields) {
if (!name.startsWith("op.")) continue
decodeFieldValue(field)?.let { result[name.removePrefix("op.")] = it }
}
return result
}

/**
* Decode a [sk.ainet.io.gguf.ReaderField] into a plain Int/Float/List<Int>/List<Float>,
* for the scalar-or-array-of-numbers shape `op.*` fields always use. `field.data` holds
* indices into `field.parts` for the field's actual value(s) — one index for a scalar,
* N indices for an N-element array (see GGUFReader.getFieldParts's ARRAY branch); each
* `field.parts[idx]` is itself a single-element list holding the raw decoded number.
*/
private fun decodeFieldValue(field: ReaderField): Any? {
val raw = field.data.mapNotNull { idx -> field.parts.getOrNull(idx)?.firstOrNull() }
if (raw.isEmpty()) return null
val numbers = raw.map { toNumber(it) ?: return null }
val isArray = field.types.firstOrNull() == GGUFValueType.ARRAY
return if (isArray) numbers else numbers.first()
}

private fun toNumber(value: Any): Number? = when (value) {
is UInt -> value.toInt()
is ULong -> value.toLong()
is Number -> value
else -> null
}

/**
* Loads all test cases from a directory recursively.
* Finds all .gguf files and loads them as test cases.
Expand Down Expand Up @@ -225,35 +265,41 @@ public operator fun List<GroundTruthTestCase>.get(testSuite: String, useCase: St

/**
* JVM-specific validator extensions with file system support.
*
* `params: OperationParams? = null` (not a bare [OperationParams] default) throughout this
* file so "not specified" can fall through to each test case's own `op.*`-derived
* [GroundTruthTestCase.resolvedParams] instead of silently overriding it with an empty one.
*/
public fun GroundTruthValidator.validate(
ggufPath: String,
params: OperationParams = OperationParams(),
params: OperationParams? = null,
tolerance: Float? = null,
rtol: Float = 1e-5f,
validateGradients: Boolean = false
): GroundTruthValidator.ValidationResult {
return validate(GroundTruthLoader.load(ggufPath), params, tolerance, rtol, validateGradients)
val testCase = GroundTruthLoader.load(ggufPath)
return validate(testCase, params ?: testCase.resolvedParams(), tolerance, rtol, validateGradients)
}

public fun GroundTruthValidator.validate(
file: File,
params: OperationParams = OperationParams(),
params: OperationParams? = null,
tolerance: Float? = null,
rtol: Float = 1e-5f,
validateGradients: Boolean = false
): GroundTruthValidator.ValidationResult {
return validate(GroundTruthLoader.load(file), params, tolerance, rtol, validateGradients)
val testCase = GroundTruthLoader.load(file)
return validate(testCase, params ?: testCase.resolvedParams(), tolerance, rtol, validateGradients)
}

public fun GroundTruthValidator.validateDirectory(
directory: File,
params: OperationParams = OperationParams(),
params: OperationParams? = null,
tolerance: Float? = null,
rtol: Float = 1e-5f
): List<GroundTruthValidator.ValidationResult> {
val testCases = GroundTruthLoader.loadFromDirectory(directory)
return testCases.map { validate(it, params, tolerance, rtol) }
return testCases.map { validate(it, params ?: it.resolvedParams(), tolerance, rtol) }
}

public fun GroundTruthValidator.validateTestSuite(
Expand All @@ -268,7 +314,7 @@ public fun GroundTruthValidator.validateTestSuite(

public fun GroundTruthValidator.assertValid(
ggufPath: String,
params: OperationParams = OperationParams(),
params: OperationParams? = null,
tolerance: Float? = null,
rtol: Float = 1e-5f
) {
Expand Down
Loading
Loading