Skip to content

feat(dotnet): add .NET SDK (Laya.Onnx) - #671

Merged
NandhaKishorM merged 23 commits into
NandhaKishorM:mainfrom
kevin-gatimu:feat/dotnet-sdk
Oct 3, 2026
Merged

NandhaKishorM merged 23 commits into
NandhaKishorM:mainfrom
kevin-gatimu:feat/dotnet-sdk

Conversation

@kevin-gatimu

@kevin-gatimu kevin-gatimu commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Closes #673

What is this

A .NET SDK in laya-dotnet/: a port of the Python SDK's inference path (tokenization, sequence building, calibration, answers) running the official checkpoints exported to ONNX, on ONNX Runtime. No Python process runs at runtime.

What it does

  • choice / score / noul answers, with confidence and answer_confidence, for all three checkpoints (multilingual, english, typed-decisions). It supports both the single-file and the split ONNX layouts.
  • It also includes the router, language detection, email, and shortlist, plus opt-in download from HF.
  • Package Laya.Onnx 0.1.0 on NuGet, versioned on its own. It's ready to publish, but I haven't published anything; that's yours to do.
  • Three samples: quickstart, routing, and benchmark.

What it unlocks

.NET services can call Laya in-process, with no Python sidecar.

Verification

  • The golden outputs were recorded from laya (Python) 0.3.21 by laya-dotnet/tools/dump_golden.py and are checked in under tests/golden/. There are 17 cases per checkpoint, plus tokenizer and JSON probes and routing probes.
  • Parity: probabilities and confidences are within 2e-4 of Python, and scores within 2e-3. The recorded values are rounded to 4 dp. Token ids and marker positions match exactly.
  • Full suite: 1368 passed, 0 failed, 0 skipped, across all three checkpoints and both layouts. In this suite every theory row counts as one test.
  • One-command parity check:
    cd laya-dotnet && LAYA_ONNX_ROOT=/path/to/onnx dotnet test --solution Laya.slnx -c Release
    Without the exported models, the model tiers print skip: and the stub and math tiers still run.

Notes for reviewers

  • Scope: only laya-dotnet/ changes. No PyPI, Python package, or CI changes.
  • Known gaps:
    • lang and email still follow Python 0.3.6. Their goldens were re-dumped, and they are unchanged.
    • The opt-in min_confidence / abstention, predict_long and lang_temperatures features aren't ported yet.
  • Proposal: publish the split-layout ONNX exports on HF next to the PyTorch weights. Today users run tools/export_onnx.py themselves, and one set of exports would serve every ONNX-based SDK.
  • Follow-ups:
    • the ONNX weights on HF;
    • a release workflow (tag laya-dotnet-v*) and publishing to NuGet;
    • bringing lang/email to 0.3.21;
    • the opt-in features above.
  • CI: the red dependency CVEs check was already failing on main before this PR. It's tracked in [bug]: dependency CVEs job in security.yml fails on main: 7 advisories in dependencies of optional extras #645 and unrelated to this change.

@kevin-gatimu
kevin-gatimu marked this pull request as ready for review September 28, 2026 09:29
@NandhaKishorM

Copy link
Copy Markdown
Owner

Holding this for a maintainer decision rather than on anything wrong with the code, and I want to be clear about which it is so you can judge whether to keep investing.

What you have built is careful. 10,100 lines of C# with 32,631 lines of golden fixtures generated against the Python path, covering all three checkpoints, both ONNX layouts, the router, language detection, email and shortlist. Golden parity fixtures are exactly how I would want a port verified, and running on ONNX Runtime with no Python process at runtime is squarely inside what open-source laya is for.

What I cannot do in a merge pass is take on a second language toolchain. Concretely:

  • There is no CI lane that builds or tests laya-dotnet, so from the moment it lands the golden fixtures stop being checked and the port silently drifts as the Python side moves. The Python side moved by 61 PRs in this release alone.
  • Laya.Onnx on NuGet is a published artifact with a release process, a version line and a deprecation story, none of which exist yet.
  • I cannot review 10k lines of C# to the standard I hold laya/ to, and nobody else has reviewed this either.

That is a real cost and it is mine to accept or decline, not yours to fix by writing more code. So: I am leaving this open and taking the decision rather than sitting on it silently.

If you want to improve the odds, the single highest-value thing is a CI workflow that builds the project and runs the golden tests on every push, with the fixtures regenerated by a script in the repo so a Python-side change that breaks parity shows up as a red check. With that in place the drift argument goes away and this becomes a much easier yes.

Same note on #672.

…ery push

Cache pinned fused and split exports, regenerate Python fixtures, and gate checkpoint-specific parity runs on TRX results. Build the SDK and samples separately, reject unexpected skips, and cover cache and skip-guard regressions.
Copilot AI balanced review requested due to automatic review settings October 3, 2026 00:22
@kevin-gatimu

Copy link
Copy Markdown
Contributor Author

Thanks, that's a fair framing, and the CI lane is the right ask. It's in now.

What's added (.github/workflows/dotnet.yml, runs on every push and PR, no path filters):

  • laya-dotnet/tools/regen_golden.py re-records the goldens from the Python laya/ at that commit
    (pinned HF revision, fused + split exports, cached), and the C# tests then run against those fresh
    goldens. The goldens are never cached, so a Python-side change that moves an answer shows up as a red
    check on the PR that made it.
  • One parity job per checkpoint (english / multilingual / typed-decisions), one for the router, and a
    build + model-free job (routing, language detection, email, shortlist goldens).
  • check_test_skips.py fails the job if any model-backed test was skipped, so missing artifacts can't
    pass as green.

Evidence it gates drift. Locally, each deliberate break turned it red:

Break Result
Changedlaya/common.py behavior 20/30 failed
Edited one golden value 1/15 failed
Broke one line of C# 11/15 failed
Removed the model files skip guard failed the run

The first real run on my fork also went red. It found genuine drift in language detection, router
reasons, email cleaning and the options-exceed-head error message since the goldens were recorded.
I ported those rule changes from Python (no tolerances loosened), rebased onto current main, and
it's now green on all 5 jobs: https://github.com/kevin-gatimu/laya/actions/runs/37080206828

Still true, and your call: NuGet publishing and its release process are deliberately not part of
this PR, and the review cost of the C# itself doesn't go away. Python features not yet in the .NET
port (they add API surface; they don't change answers for what it already covers): long prediction,
per-language temperatures, hooks, batching, option ordering, digest controls, usage diagnostics.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Cache identity, tokenizer initialization races, and Unicode parity issues remain unresolved.

Review effort: Balanced
Findings: 4 Medium severity

Open (4)
What changed in this PR

Adds the Laya.Onnx .NET SDK for in-process, local inference without a Python sidecar.

Changes:

  • Introduces typed questions, sequence building, calibration, and ONNX configuration.
  • Adds parity tests and Python-generated fixtures for three checkpoints.
  • Provides samples, benchmarking tools, and package configuration.
File Description
laya-dotnet/​tools/​test_ci_tools.py Tests skip guards and export-cache validation.
laya-dotnet/​tools/​sample_cases.py Defines shared sample inputs.
laya-dotnet/​tools/​requirements-regen.txt Pins fixture-generation dependencies.
laya-dotnet/​tools/​hashing_embedder.py Defines reference demo embeddings.
laya-dotnet/​tools/​check_test_skips.py Rejects unexpected test skips.
laya-dotnet/​tests/​Laya.Tests/​TokenizerParityTests.cs Checks tokenization and sequence parity.
laya-dotnet/​tests/​Laya.Tests/​StubTokenizer.cs Supplies a model-free tokenizer stub.
laya-dotnet/​tests/​Laya.Tests/​ShortlistEndToEndTests.cs Checks model-backed shortlist parity.
laya-dotnet/​tests/​Laya.Tests/​RoutingGoldenData.cs Loads routing reference fixtures.
laya-dotnet/​tests/​Laya.Tests/​RouterEndToEndTests.cs Tests routing and concurrent checkpoint switching.
laya-dotnet/​tests/​Laya.Tests/​PythonJsonTests.cs Checks Python-compatible JSON serialization.
laya-dotnet/​tests/​Laya.Tests/​ModelArtifactsTests.cs Tests artifact layouts and path resolution.
laya-dotnet/​tests/​Laya.Tests/​LayaEmailTests.cs Tests email cleaning and state construction.
laya-dotnet/​tests/​Laya.Tests/​Laya.Tests.csproj Configures the xUnit test project.
laya-dotnet/​tests/​Laya.Tests/​HfTokenizerFallbackTests.cs Tests special-token resolution.
laya-dotnet/​tests/​Laya.Tests/​HashingEmbedderTests.cs Checks demo embedding parity.
laya-dotnet/​tests/​Laya.Tests/​HashingEmbedder.cs Implements test embeddings.
laya-dotnet/​tests/​Laya.Tests/​golden/​typed-decisions/​meta.json Records typed-decisions reference configuration.
laya-dotnet/​tests/​Laya.Tests/​golden/​typed-decisions/​index.json Indexes typed-decisions cases.
laya-dotnet/​tests/​Laya.Tests/​golden/​typed-decisions/​case_single_option.json Records single-option behavior.
laya-dotnet/​tests/​Laya.Tests/​golden/​typed-decisions/​case_mask_injection.json Records mask-token handling.
laya-dotnet/​tests/​Laya.Tests/​golden/​typed-decisions/​case_choice_criteria_list.json Records list-based choice behavior.
laya-dotnet/​tests/​Laya.Tests/​golden/​routing/​meta.json Records routing fixture provenance.
laya-dotnet/​tests/​Laya.Tests/​golden/​multilingual/​meta.json Records multilingual reference configuration.
laya-dotnet/​tests/​Laya.Tests/​golden/​multilingual/​index.json Indexes multilingual cases.
laya-dotnet/​tests/​Laya.Tests/​golden/​multilingual/​case_single_option.json Records single-option behavior.
laya-dotnet/​tests/​Laya.Tests/​golden/​multilingual/​case_mask_injection.json Records mask-token scrubbing.
laya-dotnet/​tests/​Laya.Tests/​golden/​multilingual/​case_list_state.json Records conversation-list serialization and inference.
laya-dotnet/​tests/​Laya.Tests/​golden/​multilingual/​case_choice_criteria_list.json Records list-based choice behavior.
laya-dotnet/​tests/​Laya.Tests/​golden/​english/​meta.json Records English reference configuration.
laya-dotnet/​tests/​Laya.Tests/​golden/​english/​index.json Indexes English cases.
laya-dotnet/​tests/​Laya.Tests/​golden/​english/​case_single_option.json Records single-option behavior.
laya-dotnet/​tests/​Laya.Tests/​golden/​english/​case_mask_injection.json Records mask-token handling.
laya-dotnet/​tests/​Laya.Tests/​golden/​english/​case_choice_criteria_list.json Records list-based choice behavior.
laya-dotnet/​tests/​Laya.Tests/​CollatorTests.cs Tests batch padding and tensors.
laya-dotnet/​tests/​Laya.Tests/​CalibrationTests.cs Tests probability and confidence calculations.
laya-dotnet/​src/​Laya/​Tokenization/​ILayaTokenizer.cs Defines the tokenizer contract.
laya-dotnet/​src/​Laya/​SequenceBuilder.cs Builds question sequences and marker positions.
laya-dotnet/​src/​Laya/​Questions.cs Defines typed questions and ordered sets.
laya-dotnet/​src/​Laya/​LayaPresets.cs Supplies workflow question presets.
laya-dotnet/​src/​Laya/​LayaOptions.cs Defines checkpoint and runtime options.
laya-dotnet/​src/​Laya/​LayaConfig.cs Parses checkpoint configuration and temperatures.
laya-dotnet/​src/​Laya/​Laya.csproj Configures SDK packaging and dependencies.
laya-dotnet/​src/​Laya/​ILayaPredictor.cs Defines the common prediction interface.
laya-dotnet/​src/​Laya/​Collator.cs Constructs padded inference batches.
laya-dotnet/​src/​Laya/​Calibration.cs Calculates calibrated probabilities and confidence.
laya-dotnet/​samples/​Laya.Sample/​Program.cs Demonstrates typed predictions.
laya-dotnet/​samples/​Laya.Sample/​Laya.Sample.csproj Configures the quickstart project.
laya-dotnet/​samples/​Laya.Sample/​.gitignore Excludes generated sample files.
laya-dotnet/​samples/​Laya.Routing/​Laya.Routing.csproj Configures the routing sample.
laya-dotnet/​samples/​Laya.Routing/​DemoHashingEmbedder.cs Supplies demo shortlist embeddings.
laya-dotnet/​samples/​Laya.Benchmark/​README.md Documents benchmark usage and metrics.
laya-dotnet/​samples/​Laya.Benchmark/​Program.cs Measures latency, memory, and answers.
laya-dotnet/​samples/​Laya.Benchmark/​Laya.Benchmark.csproj Configures the benchmark project.
laya-dotnet/​samples/​Laya.Benchmark/​.gitignore Excludes generated benchmark files.
laya-dotnet/​Laya.slnx Groups SDK, tests, and samples.
laya-dotnet/​global.json Selects the SDK and test runner.
laya-dotnet/​Directory.Build.props Sets shared build properties.
laya-dotnet/​CHANGELOG.md Documents initial features and known gaps.
laya-dotnet/​.gitignore Excludes build output and local artifacts.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +92 to +93
var lowered = text.ToLowerInvariant();
return lowered.Contains('İ') ? lowered.Replace("İ", "i̇") : lowered;
public string HuggingFaceSubfolder
{
get => _huggingFaceSubfolder ?? CheckpointSubfolder(Checkpoint);
set => _huggingFaceSubfolder = value;
ids.Add(tokenizer.SepId);

var room = Math.Max(0, maxLen - ids.Count - 1);
var stateIds = tokenizer.Encode(Scrub(PythonJson.State(state), mask));
/// instead. Each test receives the checkpoint under test and loads the tokenizer inline, so missing
/// artifacts for one checkpoint skip only that checkpoint's cases.
/// </remarks>
public sealed class TokenizerParityTests
@NandhaKishorM
NandhaKishorM merged commit 4ef2a42 into NandhaKishorM:main Oct 3, 2026
NandhaKishorM added a commit that referenced this pull request Oct 3, 2026
#671's lane is the drift detection I asked for, and it works: the goldens are
re-recorded from the Python package at each commit and never cached, so a change
to laya/ that moves an answer turns this lane red.

Blocking on it would invert the dependency. The Python package is the product and
laya-dotnet follows it, so a red .NET check on a Python pull request would stop
that Python change until someone ports it to C#. The tax would land on whoever
improved the core rather than on whoever maintains the port, and at the current
merge rate that is most pull requests.

Both jobs now carry `continue-on-error`, so the lane still runs on every push
with no path filter, still attributes drift to the pull request that caused it,
and no longer gates it. security.yml's optional-dependency CVE audit is advisory
for the same reason ("visible here, not blocking PRs"). When this lane goes red
the fix is a follow-up port, not a revert.

Nothing else changes: #671 touches no file under laya/, and no gate in
tests/test_packaging.py or scripts/test_suites.py references the .NET tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@NandhaKishorM

Copy link
Copy Markdown
Owner

Merged for 0.3.26, with one change on the way in that I want to explain rather than slip past you.

You met the condition, and you met it properly

I said the blocker was drift: 77k lines in a language nothing here builds means the golden fixtures stop being checked the moment this lands. What you added closes that, and the parts I checked are the parts that matter:

  • the goldens are re-recorded from the Python package at that commit and never cached, so this is a live comparison rather than a snapshot that rots;
  • check_test_skips.py fails the run when a model-backed test skips, so missing artifacts cannot pass as green, which is the usual way a parity lane quietly stops testing anything;
  • you proved the gate bites, with four deliberate breaks each going red, instead of asserting that it would.

And the first real run finding genuine drift in language detection, router reasons, email cleaning and the options-exceed-head message, which you then ported from Python without loosening a tolerance, is better evidence than the gate design itself. That is the behaviour of someone who will keep it in step.

The change I made: the lane is advisory, not blocking

Both jobs now carry continue-on-error: true. The lane still runs on every push with no path filter, still re-records from Python, and still attributes drift to the pull request that caused it. It no longer gates that pull request.

The reason is direction, and it is a flaw in how I framed the ask rather than in what you built. Because the goldens regenerate from the Python side, a required lane means an improvement to laya/lang.py turns the .NET check red and blocks that Python pull request until someone writes C#. The cost would land on whoever improved the core, not on whoever maintains the port, and this repository is merging dozens of pull requests a day that touch core. The Python package is the product; laya-dotnet follows it.

security.yml's optional-dependency CVE audit is advisory for exactly this reason ("visible here, not blocking PRs"), so this is the existing convention here rather than a special case for your PR. When the lane goes red, the fix is a follow-up port, not a revert of the Python change.

If you would rather it were blocking, make the argument and I will listen, but that is the one thing I would want to be deliberate about.

What I am not claiming

I have not reviewed the C# to the standard I hold laya/ to, and you said as much yourself, which I appreciated. So this lands as a working port with a live parity gate, not as a peer of the Python SDK. Concretely, and I will answer issues this way:

  • NuGet publishing and its release process are out of scope here, by your choice, so there is no consumable package yet;
  • long prediction, per-language temperatures, hooks, batching, option ordering, digest controls and usage diagnostics are not ported. They add API surface rather than changing answers for what is covered, as you said, but people will ask.

Worth stating for anyone reading later: this PR touches no file under laya/. I checked. The Python package is byte-identical with or without it.

Verified here

The shared suite list reports all 79 suites passed, tests/test_packaging.py (136) and tests/test_audit_scope.py (28) pass, and a decision diff against 0.3.25 on the real english and multilingual checkpoints shows zero decision flips and a max probability delta of 0.000000, which is what "touches no file under laya/" should mean and now does.

Thank you for taking the CI ask seriously instead of arguing it. That is what moved this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[feature]: .NET SDK (​Laya.Onnx​) running on ONNX Runtime

3 participants