Repository navigation
643 lines (620 loc) · 40.3 KB
/
Copy pathjava.yml
File metadata and controls
643 lines (620 loc) · 40.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
name: Java
# Parity lane for laya-java. The fixtures under laya-java/fixtures are recorded FROM the Python
# `laya` package, so this runs on every push and pull request with no path filter: a change to
# laya/ that moves what the fixtures capture shows up here rather than as silent drift. Each run
# re-records them from the Python code at that commit and runs the Java tests against the fresh
# copies.
#
# ADVISORY, not blocking, for the same reason the .NET lane is: the Python package is the product
# and laya-java follows it, so a Python-side improvement must not be blocked on a port catching up.
# When this lane goes red the fix is a follow-up port, not a revert of the Python change.
on:
pull_request:
push:
workflow_dispatch:
#
# SUPPLY CHAIN: `laya-java/gradle/wrapper/gradle-wrapper.jar` is a binary that executes at build
# time, which makes it the one file here a reviewer cannot read. Every job below that runs
# `./gradlew` validates it against Gradle's published checksums first -- not just the first job,
# because each job checks out the tree again and runs the jar again, so validating one of them
# leaves the others executing an unverified binary. The jar in the tree today is the genuine
# 8.10.2 wrapper (sha256 2db75c40...8046, verified locally against Gradle's published value).
# The distribution the wrapper then downloads is pinned by `distributionSha256Sum` in
# `gradle-wrapper.properties`.
permissions:
contents: read
concurrency:
group: java-${{ github.event_name == 'pull_request' && github.ref || github.sha }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
# transformers probes for TensorFlow at import; when TF is present its abseil runtime can
# deadlock model construction. Laya is torch-only.
USE_TF: "0"
USE_TORCH: "1"
TOKENIZERS_PARALLELISM: "false"
jobs:
# Everything that decides whether what we publish is safe to consume, in ONE job, so a reader
# asking "is this secure" has one place to look and one status to read.
#
# NOT `continue-on-error`. Every other job in this file is advisory, and deliberately so -- the
# Python package is the product, a port follows it, and a port lagging must not block a Python
# improvement (see this file's header). A security gate is the opposite case: it is about the
# artifact this module publishes to Maven Central, where a consumer inherits what we ship and a
# published version can never be withdrawn. So this one fails the lane.
#
# What runs here, and why each earns its place rather than being added for the look of it:
#
# * the Gradle wrapper jar, validated against Gradle's published checksums. It is a binary
# that executes at build time, so it is the one file here a reviewer cannot read.
# * the folded-comment check. A `run: >` block folds to one line and `#` is not a comment in
# a YAML scalar, so a comment written inside one reaches the shell and truncates the
# command -- which silently removed `--min-tests 625 --allow-aborted` from this
# repository's own release gate. actionlint does not report it.
# * semgrep, p/java + p/security-audit. Measured 0 findings and 0 scan errors on this module
# when added, which is the property that makes it a gate worth having: a finding is a
# regression, not a backlog to triage.
# * the Java dependency tree against OSV. There was no CVE gate on it at all while the Python
# tree had pip-audit. Verified against a planted log4j 2.14.1 -- it reports Log4Shell and
# exits 1 -- and it exits 2 rather than 0 when OSV is unreachable, so a scan that did not
# run never reads as a clean tree.
#
# CodeQL is NOT duplicated here. It lives in `security.yml`, which already has the
# `security-events: write` permission and the SARIF upload, and it now covers `java-kotlin` as
# well as `python` -- this module was previously analysed by nothing.
#
# SpotBugs/find-sec-bugs was tried and rejected on evidence: 53 findings, of which 47 were
# EI/EI2 on records holding an array or a map, 4 were false positives from a `require(...)`
# helper it cannot see through, 2 were redundant `instanceof` checks (fixed directly), and 0
# were security findings. Its detectors target SQL, XXE, deserialization, crypto, command
# execution and path traversal; this module has none of those sinks. Gating on it would have
# meant maintaining a large exclusion filter for no coverage, and a large exclusion filter is
# how a scanner quietly stops scanning.
security:
name: jvm security scan
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: gradle/actions/wrapper-validation@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
# For resolving the dependency tree only; nothing here compiles the module.
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: temurin
java-version: "17"
cache: gradle
# Standard library only, so no install step.
- name: No workflow folds a comment into a run block
run: python laya-java/scripts/check_workflow_folding.py
- name: Semgrep (java + security-audit)
run: |
python -m pip install --quiet semgrep
semgrep --config p/java --config p/security-audit --error --metrics=off \
laya-java/laya-java/src/main/java
- name: Java dependencies have no known advisories
working-directory: laya-java
run: >
./gradlew --no-daemon -q :laya-java:dependencies --configuration runtimeClasspath
| python3 scripts/check_java_dependencies.py
build:
# Prefixed, because `dotnet.yml` already has jobs called "build + model-free tests" and
# "parity (english)": without a prefix the pull request's checks list shows each name twice
# and a reader cannot tell which lane failed.
name: jvm build + model-free tests
runs-on: ubuntu-latest
continue-on-error: true # advisory: see the header
timeout-minutes: 25
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# `gradle/wrapper/gradle-wrapper.jar` is a binary that executes at build time, so it is
# the one file in this tree a reviewer cannot read. This validates it against Gradle's
# published checksums before anything runs it. The jar in the tree today is the genuine
# 8.10.2 wrapper (sha256 2db75c40...8046, matching services.gradle.org and the
# gradle/gradle v8.10.2 tag); this keeps a later swap from going unnoticed.
#
# In EVERY job that runs ./gradlew, not just this one: each job checks the
# tree out again and runs the jar again, so validating one leaves the others
# executing an unverified binary. release-java.yml's `central` job is the
# reason it matters -- it runs the wrapper holding the signing key and the
# Maven Central credentials, and Central is append-only.
- uses: gradle/actions/wrapper-validation@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: temurin
java-version: "17"
cache: gradle
# The model-free families need no checkpoint, so their drift is checked here. The rest are
# left alone by the generator and reported as unverified; the parity job checks those.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install the Python toolchain the fixtures were recorded with
run: |
python -m pip install --upgrade pip
pip install "torch==2.14.0" --index-url https://download.pytorch.org/whl/cpu
pip install -r laya-dotnet/tools/requirements-regen.txt
# -Werror is on, so this fails on a warning as well as an error.
- name: Build (warnings are errors)
working-directory: laya-java
run: ./gradlew --no-daemon build -x test
# Without LAYA_CHECKPOINTS the checkpoint-backed factories abort by assumption and the rest
# run. `--min-tests` is what stops an aborted suite from looking like a pass.
#
# Measured on the merged tree (main 68804629, release 0.4.2): 1192 register here, 1168 run,
# 24 abort on a checkpoint this lane does not have -- the floor counts what ran, so 1168.
# Exact, not padded -- the floor stood at 680 long enough for the real figure to pass it by,
# which is how a floor stops being one. ONE below the JDK-matrix lane's floor on purpose:
# that lane passes `-PtestJavaVersion`, which is what makes `TestJvmVersionTest` run instead
# of skip, so it executes 1169. Same suite, one more test, because the flag enables it. The
# step below is where the number is justified.
- name: Tests that need no model
working-directory: laya-java
run: ./gradlew --no-daemon test
# Measured on the merged tree, Corretto 17, no checkpoints: 1192 registered, 24 abort,
# 1168 RUN -- the floor counts what ran, so the aborted checkpoint-backed cases are not in
# it. The floor is EXACT, with no margin, which is a change from the margin-of-4 this
# replaced. A margin only binds classes larger than itself, and three classes in this lane
# run exactly ONE test (`DecoderTest`, `TypedDecisionsParityTest`, `WindowPlanTest`), so no
# positive margin can bind them -- any slack at all lets a whole class be deleted with this
# step still green. Four branches each raised this floor against their own base (779, 686,
# 813); every one of those would have left over a hundred tests of slack here, which is why
# the number is re-derived on the merge rather than reconciled from the four.
#
# This floor and the jdk-matrix one differ by one ON PURPOSE -- see that lane for why a
# shared number let a whole five-test class be deleted there.
# 1007/1008 was the pre-#1061 measurement, kept on purpose because #1057, #1059, #1060 and
# #1061 were each branched off main independently and none measured the merged tree, which
# carries all four. That measurement now exists: on merged main 68804629 (release 0.4.2),
# Actions run 38041041968, this lane's job
# https://github.com/NandhaKishorM/laya/actions/runs/38041041968/job/114182848558
# registered 1192, aborted 24, ran 1168, and failed 0; the jdk-matrix lane on the same
# run (job 114182848539) registered 1192, aborted 23, ran 1169, and failed 0. So the floor
# is 1168, not 1007 -- the pre-#1061 number sat 161 below what actually runs, which is
# silent loss this step exists to see.
- name: Model-free suite was not vacuous
if: ${{ !cancelled() }}
run: >
python laya-java/scripts/check_test_results.py
laya-java/laya-java/build/test-results/test --min-tests 1168 --allow-aborted
# The samples are single-file source programs, deliberately NOT a Gradle module: a
# `samples` subproject would inherit the root build's MavenPublication and
# `publishAllPublicationsToCentralRepository` would push it, which is why the empty
# `laya-java-client` module was taken out of settings.gradle.kts in the first place. A
# version burned on Central cannot be replaced.
#
# Compiled here so they cannot rot: a sample that stops matching the API fails the build
# rather than sitting in the tree misleading a reader. `-Werror` because the samples are the
# first code a user copies.
- name: The samples still compile against the built classes
working-directory: laya-java
run: |
mkdir -p "$RUNNER_TEMP/samples"
javac -Xlint:all -Werror \
-cp "laya-java/build/classes/java/main:$(find ~/.gradle/caches/modules-2 \
-name 'onnxruntime-*.jar' | head -1)" \
-d "$RUNNER_TEMP/samples" samples/*.java
echo "compiled:"; find samples -name '*.java' -print
# LAST, deliberately. This gate and the tests are independent signals -- the tests compare
# against the COMMITTED fixture, this compares the committed fixture against a fresh
# recording -- and running it first meant a stale fixture skipped the build and the tests
# entirely, so the run said nothing about whether the code worked.
- name: Model-free fixture drift vs. committed
if: ${{ !cancelled() }}
# Every family, not one. `--only python_json.json` left lang_detect, lang_tables,
# presets, router and shortlist gated NOWHERE: a change to laya/ could move the contract
# those five record and every check stayed green, because the Java tests assert against
# the stale committed copy -- exactly the failure the fixtures exist to prevent. The
# generator already leaves checkpoint-backed families alone and reports them unverified,
# so a bare --check is the right scope.
#
# On failure, SHOW the drift. `--check` printed only "<family> is stale", which named the
# file and nothing else -- and diagnosing a cross-platform float difference from that took
# three CI round trips. Regenerating and diffing costs nothing on the passing path, which
# is the common one.
run: |
if python laya-java/scripts/gen_fixtures.py --check; then
exit 0
fi
echo "::group::what actually drifted"
python laya-java/scripts/gen_fixtures.py || true
git --no-pager diff --stat -- laya-java/fixtures
git --no-pager diff -U0 -- laya-java/fixtures | head -120
echo "::endgroup::"
exit 1
# The compiled-in Unicode tables are recorded from CPython, so a CPython upgrade that moves
# them has to show up here rather than as a routing change nobody attributed.
- name: Unicode tables match CPython
if: ${{ !cancelled() }}
run: python laya-java/scripts/gen_unicode_tables.py --check
- name: Language tables match laya/lang.py
if: ${{ !cancelled() }}
run: python laya-java/scripts/gen_language_tables.py --check
# The pre-tokenizer's letter/number classes are probed from the reference tokenizer, so this
# one needs a checkpoint and runs in the parity lane instead -- see that job.
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: ${{ failure() }}
with:
name: java-build-diagnostics
path: laya-java/laya-java/build/reports
# The SDK is compiled for 17 and the toolchain pins the COMPILER to 17, so a lane that merely
# installs another JDK still compiles and tests on 17 and proves nothing. `-PtestJavaVersion`
# moves only the JVM that executes the tests; `options.release` keeps the bytecode at 17, so
# what a consumer downloads is byte-for-byte the same artifact.
#
# This axis exists because a JDK-version dependency shipped. `\p{L}` and `\p{N}` in
# java.util.regex follow the JDK's own Unicode version, so the pre-tokenizer gave different
# token ids from the same jar on different JDKs. Corretto 17 carries Unicode 13.0 and Corretto
# 24 carries 16.0: `Character.isLetter` disagrees with itself across them on 751 of the code
# points this port classifies, answering 0 of 751 on one and 751 of 751 on the other.
#
# The model-free suite is the right scope for it, because the JDK-sensitive tests are exactly
# the ones that need no model: the Unicode tables (a sha256 over all 1,114,112 code points),
# the White_Space predicate, the pre-tokenizer's letter and number classes, CPython's float
# repr and the rounding rules. All of those are compiled in FROM the reference, so the answer
# must not move when the host JDK's own Unicode does -- and that is what this measures.
jdk-matrix:
name: jvm model-free tests (jdk ${{ matrix.jdk }})
runs-on: ubuntu-latest
continue-on-error: true # advisory: see the header
timeout-minutes: 25
strategy:
fail-fast: false
matrix:
# 17 as well as 21 and 24, even though the build job also tests on 17: that job runs
# WITHOUT `-PtestJavaVersion`, so `TestJvmVersionTest` is disabled there and "17 is
# tested" was true but unasserted. Here it is asserted.
#
# The three are not an arbitrary spread, they are three different Unicode versions --
# 17 carries 13.0, 21 carries 15.0 and 24 carries 16.0 -- and this port's whole
# character-handling strategy is that the answer must not depend on which. That is not
# theoretical: the JDK 21 cell failed on its first run, because `Character.isLetter` on
# Unicode 15.0 agrees with CPython 15.0 exactly, and a test had asserted the JDK
# DISAGREES. 17 and 24 passed. A two-version matrix would not have found it.
jdk: ["17", "21", "24"]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Validate the wrapper jar BEFORE anything can execute it. See this file's
# header for why every job that runs ./gradlew needs this, not just one.
- uses: gradle/actions/wrapper-validation@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4
# For check_test_results.py only -- it is standard library, so there is nothing to install.
# Pinned rather than relying on whatever `python` the runner image happens to expose.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
# Both: 17 for the compiler toolchain, the matrix JDK for the test JVM. setup-java puts
# them under the hosted tool cache, which Gradle's own directory scan does not look in, so
# `fromEnv` names the variables it exports.
#
# These are `-P`, not `-D`, and that is not a style choice: Gradle ignores
# `org.gradle.java.installations.*` given as `-D`. Measured on this build --
# `auto-detect=false` as `-D` left all 8 locally detected toolchains in the
# `javaToolchains` report; as `-P` it dropped to 3. With `-D` the lane still passed, but
# only because setup-java makes the LAST java-version the JAVA_HOME, so Gradle's
# "current JVM" supplier happened to provide the right launcher -- a coincidence, and the
# exact fragility naming the variables was meant to remove.
#
# With `-P` the three together make a misconfiguration loud: `auto-detect=false` means the
# only installations are the two named, and `launcherFor` with `auto-download=false` then
# fails the build outright -- "Cannot find a Java installation on your machine matching:
# {languageVersion=N}" -- instead of quietly running on the compile JDK. Verified both
# ways: without `fromEnv` the build exits 1 with that message; with it, 0.
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: temurin
java-version: |
17
${{ matrix.jdk }}
cache: gradle
- name: Tests that need no model, on JDK ${{ matrix.jdk }}
working-directory: laya-java
run: >
./gradlew --no-daemon
-Porg.gradle.java.installations.fromEnv=JAVA_HOME_17_${{ runner.arch }},JAVA_HOME_${{ matrix.jdk }}_${{ runner.arch }}
-Porg.gradle.java.installations.auto-detect=false
-Porg.gradle.java.installations.auto-download=false
-PtestJavaVersion=${{ matrix.jdk }}
test
# That the tests ran on the RIGHT JVM is asserted inside the suite, by
# `TestJvmVersionTest` against the `laya.test.expectedJavaVersion` the build sets from
# `-PtestJavaVersion`. It cannot be checked from here: Gradle writes `<properties/>` empty
# into the JUnit XML, so the report cannot tell 17 from 24.
#
# `--require-passed` is what stops that assertion being optional. The test is
# `@EnabledIfSystemProperty`, so if `-PtestJavaVersion` ever stops reaching the build it is
# SKIPPED rather than failed -- and `--allow-aborted`, which this lane needs because the
# checkpoint-backed factories abort here, would forgive it. Measured: with the flag
# dropped, Gradle exits 0 and the floor alone exits 0 on 488 tests, having run JDK 17 twice
# and reported it as two JDKs. With `--require-passed` it exits 1.
#
# 1169, not the build lane's 1168, and the one is the point: this lane runs one test MORE
# than that one, because `TestJvmVersionTest` is `@EnabledIfSystemProperty` and only
# enabled here. Both are EXACT, with no margin, for the reason the build lane gives --
# three classes here run exactly one test, so no positive margin can bind them.
#
# A shared number is not a theoretical problem. Measured on an earlier shared floor of
# 794, with the smallest `hooks.json` section emptied and no per-section guard: the build
# lane ran 793 and exited 1 by exactly one test, while this lane ran 794 -- exactly ON
# the floor, so `check_test_results` exited 0 and the vacuity was invisible in the one
# lane that had the margin to hide it. The emptiness guard inside `HooksTest.section` is
# the real fix for that case; the per-lane floors keep the arithmetic from being what
# saved it.
#
# The exact floor was prepared without a JDK 21 to test on, so whether 21 registers the
# same count as 17 and 24 was an argument -- no JDK-version-gated assumptions exist, and
# 17 and 24 agreed -- rather than a measurement. CI has since run all three green at
# these floors, so it is a measurement now.
- name: Model-free suite was not vacuous, and ran on the right JVM
if: ${{ !cancelled() }}
run: >
python laya-java/scripts/check_test_results.py
laya-java/laya-java/build/test-results/test --min-tests 1169 --allow-aborted
--require-passed TestJvmVersionTest
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: ${{ failure() }}
with:
name: java-jdk${{ matrix.jdk }}-diagnostics
path: laya-java/laya-java/build/reports
# Split along the axis that actually costs something: whether the cell needs an exported ONNX
# graph. Three of the four checkpoint-backed test classes need only `tokenizer.json` and
# `rl_agent_config.json`, which are kilobytes; one needs the traced graph, which is gigabytes
# and is the whole runtime of this lane. Running them in one job made a 13-second check wait on
# a 40-minute export.
#
# The GRAPHLESS cell is not split per checkpoint, which is where the .NET lane splits. Two
# reasons, both measured. The fixtures it owns are not per-checkpoint documents --
# `tokenizer_ids.json`, `sequences.json` and `decode.json` each carry BOTH checkpoints'
# sections in one file, so a job holding one of them cannot regenerate any of the three and
# `--strict` fails by construction; that is what happened on the first run of this lane. And
# the Java tests assert that both checkpoints' cases are present, a floor that exists because a
# @TestFactory whose fixture key emptied used to generate zero assertions and still exit 0.
# Splitting it per checkpoint would mean deleting that floor to buy nothing: the two tokenizers
# together are a few megabytes, so there is no export to parallelise away.
#
# The WEIGHT-BEARING cells are one per checkpoint, and have to be. A graph is traced from
# exactly one checkpoint and carries its weights, so `predict.json` (multilingual) and
# `typed_decisions.json` (typed-decisions) need two graphs; a cell holding both would pay both
# exports serially, which is the cost this whole split exists to avoid. Each such cell declares
# the checkpoint it traces in `graph-model`, which is also what keys its export cache and which
# of the two graph variables gets a path -- so adding a third never silently reuses the second's
# graph.
parity:
name: jvm parity (${{ matrix.name }})
runs-on: ubuntu-latest
continue-on-error: true # advisory: see the header
timeout-minutes: ${{ matrix.timeout }}
strategy:
fail-fast: false
# One cell at a time, because every cell that needs weights downloads them from the same
# Hugging Face repo UNAUTHENTICATED, and the hub rate-limits that. This matrix grew a third
# cell in this change, and `dotnet.yml`'s parity matrix runs four of its own against the
# same repo in the same workflow run, so the peak was seven concurrent unauthenticated
# fetches of one repo. Under that, `hf-xet` returned a TRUNCATED file without raising: the
# 34 MB multilingual `tokenizer.json` arrived empty, the download step reported success, and
# the failure surfaced ninety seconds later as `EOF while parsing a value at line 1
# column 0` from inside a tokenizer constructor. Reproduced twice on this branch while the
# same job passed on a branch that adds no cell.
#
# Serialising costs wall-clock on an advisory lane and removes the pressure this change
# added. It is one of three parts, not the whole fix: `prepare_checkpoint.py` now refuses an
# incomplete snapshot at the fetch -- a download that produced nothing, a missing file, a
# 0-byte or unparseable one -- and the fetching step carries `HF_TOKEN` so the higher rate
# limit applies where the secret exists. The guard is what matters; not provoking the
# throttle is merely cheaper than diagnosing it.
#
# TWO, not one. One was the first attempt and it cost too much: measured from this PR's own
# cells, serialising takes the parity critical path from 10:56 to 30:08. Two keeps the peak
# well below the seven concurrent fetches that provoked the throttle while costing roughly
# half of that. And serialising is no longer the load-bearing half of the fix -- the
# download sites now refuse an incomplete snapshot AT THE FETCH, naming the file, so a
# throttled fetch fails immediately and legibly instead of three minutes later inside a
# tokenizer constructor. This only reduces how often that path is taken.
max-parallel: 2
matrix:
include:
# Tokenizers and configs only -- no weights, nothing traced. The three families here
# are the ones whose builders read a checkpoint but never a graph.
# `predict_long.json` is here and not in the `predict` cell on purpose: its generator
# STUBS inference, so it needs a tokenizer and an rl_agent_config and no traced graph.
# Putting it in the weight-bearing cell would make a 13-second check wait on a
# 40-minute export for nothing.
- name: tokenizer
checkpoints: english multilingual
graph-model: "" # nothing traced here
families: >-
--only tokenizer_ids.json --only sequences.json --only decode.json
--only decode_text.json --only window_plan.json --only predict_long.json
tests: >-
--tests com.convaiinnovations.laya.tokenizer.TokenizerParityTest
--tests com.convaiinnovations.laya.sequence.SequenceParityTest
--tests com.convaiinnovations.laya.decode.DecoderParityTest
--tests com.convaiinnovations.laya.tokenizer.PreTokenizerTablesTest
--tests com.convaiinnovations.laya.tokenizer.TokenDecoderTest
--tests com.convaiinnovations.laya.sequence.WindowPlanTest
--tests com.convaiinnovations.laya.PredictLongTest
# Measured locally: 493. The margin is deliberately smaller than the 8 the 183/175
# pair used, because the smallest class in this cell is PreTokenizerTablesTest at 5
# tests and a margin of 8 let that whole class be dropped with the floor still green
# (measured at the time: 369 of 374, exit 0). A floor below the total minus the
# smallest class does not bind any single class, so this stays within 5 of the total.
#
# What this does NOT protect, despite an earlier version of this comment saying so:
# the window SLICES. A count floor cannot see them -- the 403 slice assertions live
# inside 46 of the ~195 dynamic tests, because the factory bundles all of one text's
# slices into a single test, and a slice list that emptied would still register its
# test. The slices are guarded inside the suite instead, by the `total >= 400`
# assertion in TokenDecoderTest and the `cases.size() >= 23` corpus floor beside it.
min-tests: 489
timeout: 25
# Weight-bearing: the multilingual end-to-end golden, traced from that checkpoint.
- name: predict
checkpoints: multilingual
graph-model: multilingual
families: --only predict.json --only tournament_golden.json
tests: >-
--tests com.convaiinnovations.laya.PredictParityTest
--tests com.convaiinnovations.laya.OneOptionGraphTest
--tests com.convaiinnovations.laya.TournamentParityTest
# 12 is a verified LOWER bound, not the union: #1059 and #1061 were branched
# off main independently and each measured 12 for its own pair, so neither
# measured all three classes together. Two of them use @TestFactory, so the
# executed count is not derivable statically. Re-measure on main and raise it.
min-tests: 12
timeout: 75
# Weight-bearing, and a SEPARATE cell rather than a second family in the one above.
# Three reasons, in order of how much they cost:
#
# * A graph is traced from one checkpoint. `predict.json` is recorded against
# multilingual and `typed_decisions.json` against typed-decisions, so one cell
# holding both would export two graphs and run two exports serially -- the 40
# minutes this matrix was split to avoid, paid twice in one cell instead of once
# in each of two that run in parallel.
# * The export cache is keyed per checkpoint (`graph-model` is in the key), so a
# restore brings back ONE graph and a miss re-exports one. A merged cell would key
# two graphs under one entry and re-export both on any miss, including the 1.7 GB
# it already had. Not "each checkpoint's graph is invalidated by its own revision
# bump" -- there is no such thing: `prepare_checkpoint.py` pins a single
# `HF_REVISION` for every checkpoint, and that file is inside this key's
# `hashFiles(...)`, so a bump invalidates every cell at once. What stops a restored
# pre-bump cache from being reused is the `.laya-revision` stamp `stamped()` reads,
# not the key.
# * These two goldens fail for unrelated reasons. A merged cell reports one status
# for both, and the first thing a reader of a red parity lane needs to know is
# which checkpoint moved.
#
# Cost, measured locally with the Hub blobs already cached: 846 MB of checkpoint, a
# 39 s ONNX export producing 3.6 MB of graph beside 1.7 GB of external weights, 103 s
# to record the family and 140-180 s for the 14 tests. On a cold runner the download is
# the open variable and is why this carries the same 75-minute ceiling as `predict`.
- name: typed-decisions
checkpoints: typed-decisions
graph-model: typed-decisions
families: --only typed_decisions.json
tests: --tests com.convaiinnovations.laya.TypedDecisionsParityTest
# Measured locally: 14 -- 9 single-state cases, 3 batch configs, the config identity
# check and the workflow-signature check. Exact rather than padded down, because the
# two non-factory tests are what catch a wrong checkpoint/graph pairing and a margin
# of 2 would let both be dropped with this floor still green.
min-tests: 14
timeout: 75
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# Validate the wrapper jar BEFORE anything can execute it. See this file's
# header for why every job that runs ./gradlew needs this, not just one.
- uses: gradle/actions/wrapper-validation@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d # v4
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1
with:
distribution: temurin
java-version: "17"
cache: gradle
- name: Install the Python toolchain the fixtures were recorded with
run: |
python -m pip install --upgrade pip
pip install "torch==2.14.0" --index-url https://download.pytorch.org/whl/cpu
pip install -r laya-dotnet/tools/requirements-regen.txt
# The export is the slow part (download, trace, verify) and depends on the PINNED REVISION
# as well as the exporter and the model definition, so `prepare_checkpoint.py` -- which is
# where HF_REVISION lives -- is part of the key. It was not, and that was not cosmetic:
# `prepare_checkpoint.py` short-circuits on an existing graph, so a revision bump changed
# nothing in the old key, the graph traced from the PREVIOUS revision was restored and
# reused, and `predict.json` was then recorded from that same stale graph. Both sides of
# the comparison agreed, nine tests passed, and the cell was green having never measured
# the pinned checkpoint -- with the drift step showing `predict.json` unchanged, which
# reads as reassurance. The script now also stamps the revision beside the graph and
# re-exports when it differs, so this key is defence in depth rather than the only guard.
#
# There is NO Hugging Face download cache, deliberately. There was one, on
# `~/.cache/huggingface/hub`, and it never held anything: `prepare_checkpoint.py` passes
# `local_dir` to `snapshot_download`, which writes the files there and not into the hub
# cache. Measured against the pinned huggingface_hub -- with a `revision` pinned, as here,
# the hub cache is not even created; without one it gets a `refs/main` pointer and zero
# blobs. So the step always missed on restore, saved nothing, and read like a download
# cost that was being avoided when it was being paid in full every run. Making it real
# means giving `snapshot_download` a `cache_dir`, or caching `.work/checkpoints` (which
# the revision stamp now makes safe) and accepting a multi-gigabyte entry for the
# weight-bearing cell; neither is this change's call to make.
- name: Cache the exported graph
if: ${{ matrix.graph-model != '' }}
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: laya-java/.work/onnx
key: laya-java-onnx-${{ runner.os }}-${{ matrix.graph-model }}-${{ hashFiles('laya-java/scripts/prepare_checkpoint.py', 'scripts/export_onnx.py', 'laya/common.py', 'laya-dotnet/tools/requirements-regen.txt') }}
# A graph is exported for the ONE checkpoint this cell's golden was recorded from, and
# every other checkpoint it needs is fetched `--no-graph`. Keyed on the name rather than on
# a boolean, because `graph: true` could not say WHICH checkpoint to trace, and a cell that
# names two would otherwise have traced both.
- name: Prepare the checkpoints this cell owns
env:
# Scoped to this step, not the workflow: a workflow-level `env` reaches every step of
# every job, including `pip`, `gradle`, `semgrep` and third-party actions that have no
# business holding a credential. Only the steps that fetch from the hub get it.
#
# Unauthenticated requests are rate-limited, and a throttled fetch is what arrived
# TRUNCATED here. Unset expands to "", which the implicit token path treats as
# anonymous -- the explicit `token=` call sites normalise "" to None themselves,
# because an empty Bearer header makes httpx refuse the request outright.
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
for c in ${{ matrix.checkpoints }}; do
if [ "$c" = "${{ matrix.graph-model }}" ]; then
python laya-java/scripts/prepare_checkpoint.py --checkpoint "$c"
else
python laya-java/scripts/prepare_checkpoint.py --checkpoint "$c" --no-graph
fi
done
# Re-recorded from the Python code at THIS commit. `--strict` makes a family this cell could
# not regenerate an error rather than a skip: here a skip means the download or the export
# failed, not that the machine has no model. It is scoped by `--only` to the families this
# cell owns, and between the cells every checkpoint-backed family is covered.
- name: Regenerate this cell's fixtures from Python
env:
LAYA_FIXTURE_CHECKPOINTS: ${{ github.workspace }}/laya-java/.work/checkpoints
# One variable per golden, and only this cell's is populated. They cannot share one:
# a graph carries the weights and nothing in it says which checkpoint traced it, so
# `predict.json` pointed at the typed-decisions graph would re-record itself from the
# wrong weights and compare them against themselves. Measured: the english checkpoint
# against the typed graph raises nothing and answers to the same last digit.
LAYA_ONNX_GRAPH: ${{ matrix.graph-model == 'multilingual' && format('{0}/laya-java/.work/onnx/multilingual/laya.onnx', github.workspace) || '' }}
LAYA_PREDICT_MODEL: multilingual
LAYA_TYPED_ONNX_GRAPH: ${{ matrix.graph-model == 'typed-decisions' && format('{0}/laya-java/.work/onnx/typed-decisions/laya.onnx', github.workspace) || '' }}
run: python laya-java/scripts/gen_fixtures.py --strict ${{ matrix.families }}
# The pre-tokenizer's letter and number classes are probed out of the reference tokenizer,
# so this is the one generator gate that needs a checkpoint. It belongs to the cheap cell.
- name: Pre-tokenizer class tables match the reference
if: ${{ matrix.graph-model == '' }}
run: >
python laya-java/scripts/gen_pretokenizer_tables.py --check
--tokenizer laya-java/.work/checkpoints/english/tokenizer/tokenizer.json
# Informational: float noise across platforms is expected in the model-backed family, and
# the Java test's tolerance is the gate rather than byte equality of the file.
- name: Fixture drift vs. committed (informational)
run: |
git --no-pager diff --stat -- laya-java/fixtures
git status --short -- laya-java/fixtures
- name: Run this cell's tests against the fresh fixtures
working-directory: laya-java
env:
LAYA_CHECKPOINTS: ${{ github.workspace }}/laya-java/.work/checkpoints
LAYA_ONNX_GRAPH: ${{ matrix.graph-model == 'multilingual' && format('{0}/laya-java/.work/onnx/multilingual/laya.onnx', github.workspace) || '' }}
LAYA_TYPED_ONNX_GRAPH: ${{ matrix.graph-model == 'typed-decisions' && format('{0}/laya-java/.work/onnx/typed-decisions/laya.onnx', github.workspace) || '' }}
run: ./gradlew --no-daemon test ${{ matrix.tests }}
# A checkpoint-backed factory that aborts is not a pass. With this cell's checkpoints present
# nothing it runs may abort, and the floor catches a suite that silently stopped registering
# its cases -- which is a thing that has happened here: five @TestFactory bodies generated
# zero assertions when their fixture key emptied, and the suite still exited 0.
- name: No parity test was skipped
if: ${{ !cancelled() }}
run: >
python laya-java/scripts/check_test_results.py
laya-java/laya-java/build/test-results/test --min-tests ${{ matrix.min-tests }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: ${{ failure() }}
with:
name: java-parity-${{ matrix.name }}-diagnostics
path: |
laya-java/laya-java/build/reports
laya-java/fixtures