0001 the foundations target frameworks comparison unit persistence and versioning - CyrilB1531/lodestar GitHub Wiki
Status: accepted ยท Date: 2026-09-20
Four choices were made before the second package existed, and every package since has been
written under them: what a library compiles against, what a character-based algorithm counts as
one character, what a fitted model looks like on disk, and what a version number is a statement
about. They are merged here from 0001, 0002, 0011, 0012 and 0055, restated as what is
true today rather than as what each record found on the day it was written.
Each was a choice between live options. net8.0 (LTS) was the brief's suggestion and
netstandard2.0 was declined once before being taken. The comparison unit could have been the
UTF-16 unit, the code point or the grapheme cluster. Persistence could have been BinaryFormatter,
a third-party serializer or a hand-written reader, and the repository could have kept the single
repository-wide <Version> it started with.
Every package multi-targets net10.0 and netstandard2.0, and the two builds expose the same
public API (0001). netstandard2.0 reaches equivalent behaviour through conditional
compilation, never a reduced surface; it widens reach to .NET Framework 4.6.1+, Mono, Xamarin and
Unity, which matters because a package on nuget.org has no identified consumer by definition.
Gaps are closed in a fixed order: PolySharp compile-time polyfills, then System.Memory 4.6.3 and
System.Numerics.Vectors 4.6.1 referenced only on that target, then a hand-written fallback in
src/Shared/ compiled into every library under Lodestar.Internal, so no call site carries an
#if. Executables โ tests, benchmarks โ stay on net10.0: netstandard2.0 is a contract, not a
runtime. The one deliberate behavioural split is VectorMath.Dot, Vector<T> on net10 and a
scalar loop below it; R2 and ExplainedVariance add a lane-wise Neumaier sum whose two targets
are not guaranteed bit-identical, which is narrower than the bit-identity
Pooler.MeanPoolBatch asserts.
-
Drift.
0001says "the three libraries". There are eighteen packages now, seventeen of them onnet10.0;netstandard2.0andLodestar.Gpuonnet10.0;netstandard2.1, because ILGPU publishes nonetstandard2.0asset (0101, as amended by0103). "Three mirror test projects" is likewise eighteen:dotnet test Lodestar.slnx -c Releasemust report 36 assemblies.
The comparison unit is the UTF-16 code unit by default, and the code point on request
(0002). A Python str iterates code points and a .NET string iterates UTF-16 code units, so a
character outside the Basic Multilingual Plane occupies two positions here and one there โ the
single largest source of divergence from the reference libraries. TextElement.Utf16Unit is 0
and therefore the default: allocation-free, and in agreement with Python for all BMP text.
TextElement.CodePoint is offered on every affected algorithm and costs one pooled decode pass
per operand; it is the mode the oracle corpora were generated under and are replayed with, because
rapidfuzz and jellyfish work on code points. Grapheme clusters are deferred โ they need
segmentation, they allocate, and no targeted Python library offers an oracle for them. Lone
surrogates are preserved at their unit value, like a Python str, rather than throwing.
A persisted artifact is one versioned JSON document, written and read with System.Text.Json
(0011). A loaded file is untrusted input, so BinaryFormatter and polymorphic deserialization
are out, and so is the pickle.load habit these APIs mirror. System.Text.Json is the single
deliberate exception to the no-external-dependency rule: in-box from net8.0, and taken as a
package pinned at 10.0.12 on netstandard2.0 alone, declared by Lodestar.Text and
Lodestar.Embeddings โ the two packages that ship artifacts โ and by no other.
tools/check_nuspec_dependencies.py asserts exactly that per target framework, and
THIRD-PARTY-NOTICES.md records it. What the exception buys is not a dependency avoided but a
hand-written JSON parser with a security boundary running through it; the minimal protobuf reader
in SentencePieceModelLoader is the counter-example that shows the line is real, four wire types
against a frozen format with no in-box alternative at all.
The contract the format carries:
| element | rule |
|---|---|
| header |
$schema names the artifact kind and version is numbered per artifact |
| unknown properties | rejected, naming the property โ ignoring them loads a model nobody saved |
| a single double | shortest round-trippable form on net10, invariant "G17" on netstandard2.0
|
| non-finite values | refused on write, and refused again on read of the raw-bits block |
| the idf vector | one base64 string of little-endian IEEE-754 bits, decoded by the same library |
| the vocabulary | stays a plain JSON array of strings: it is what a person reads |
| escaping |
JavaScriptEncoder.UnsafeRelaxedJsonEscaping, since accented tokens are the ordinary case |
| bounds | vocabulary 1 000 000, token 1024 chars, JSON depth 32, 256 MiB total, array 1 000 000 |
| API |
Save(Stream), Save(string), static Load(โฆ, ArtifactLoadOptions?), async counterparts |
Every count read from a file sizes a buffer, so every count is checked before use and exceeding a
limit is InvalidDataException naming both the limit and the value. A stream passed in is never
disposed; the path overloads own the FileStream they open.
The artifact gets a binary sidecar only once a block can be ingested whole, and that condition
is the decision (0055). Measured on the persistence corpus, 10 000 ร 384 with ids: the .npy
block plus head is 15 469 135 bytes against the artifact's 20 589 007, 1.331ร smaller, and
EmbeddingIndex.Load at 11.834 ms against a sidecar floor of 5.847 ms is 2.02ร โ so the size
argument 0011's own #324 update allowed is joined by a speed one, because the cost is the JSON
scan around the block and not the base64. But rebuilding the index vector by vector cost 17.973 ms,
slower than the artifact it would replace, so the order is fixed: bulk ingest first, sidecar
second, head stays JSON and stays the artifact's own. The precondition is now met โ
EmbeddingIndex.FromBlock and FromOwnedBlock shipped under 0056 โ and the sidecar itself has
not.
Each publishable package declares its own version in a sibling Version.props, and reaches a
sibling through a PackageReference on a published floor (0012). A repository-wide <Version>
republished packages at a number describing no change in them. The file holds a named property,
LodestarTextVersion and its siblings, because three places need the number for three different
reasons: the csproj's own identity, the floor in src/Directory.Packages.props, and the version
samples/ has just packed. The floor is chosen, not tracked โ Lodestar.Text is pinned at
0.6.0 there while src/Lodestar.Text/Version.props declares 0.6.1 โ which is what makes
git clone && dotnet build work with no pack step, and what makes raising it a semver decision.
A declared version never equals one already on the feed, and tools/check_version_floor.py
enforces that. LodestarUseProjectRefs=true is the local editing loop and is never set by CI,
which asserts the shipped path through evaluated MSBuild rather than by grepping for
<ProjectReference. Tags are <PackageId>/v<Version>; the tag says which declared version to
release and never sets it. A version tool โ MinVer, Nerdbank.GitVersioning, GitVersion โ was
declined because all three derive a number from git topology, and this number is a deliberate
statement about a public API.
-
Drift.
0012records "exactly one inter-project reference undersrc/"; there are sixteen edges now. Its rule that nosrc/project carries aProjectReferencehas one documented exception,Lodestar.Stats.TimeSeriesโLodestar.Stats.Regression, held there until that package's 0.2.0 is published (0133, issue #671).
-
Both builds are executed, not merely compiled. The
*.NetStandard.Testsmirrors link the same test sources and pinSetTargetFramework=netstandard2.0, so a new test file is picked up by both automatically.SetTargetFrameworkdoes not cross aPackageReferenceโ NuGet resolves package assets against the mirror's own framework โ so a mirror pins everyLodestar.*dependency, transitive ones included, with its ownProjectReference;NetStandardAssemblyGuardTestsasserts one fact per assembly it must prove it loaded. -
The
net10.0packages remain dependency-free, so the README's claim holds where most consumers read it, with thenetstandard2.0qualifier it now carries. -
A cross-package change is two pull requests and a release, not one branch: a change to
Lodestar.Textis not seen byLodestar.Fuzzyuntil it is published, which is exactly what a consumer gets.CHANGELOG.mdstays one file with per-package headings. -
The sample judges the packed packages only under an isolated
NUGET_PACKAGES, because the global packages folder is consulted ahead of every source and would otherwise serve the released assembly while appearing to validate the working tree. -
The pre-rename names survive in one place that matters.
0011and0012were written when the project wasDataNet;DataNetUseProjectRefsisLodestarUseProjectRefstoday, butArtifactHeader.SchemaForstill emitsdatanet/<artifact>. That string is part of the artifact contract โ changing it stops every file already written from loading โ so it is a name to recognise, not one to fix in passing. -
ArtifactLoadOptionsis still declared once per package, inLodestar.Text.PersistenceandLodestar.Embeddings.Persistence, rather than shared: sharing would need either an edge onto a published package that does not yet contain the type, or one public type in two assemblies, which is an ambiguous reference for anyone consuming both. The reading helpers behind it are shared, asinternaltypes inLodestar.Internal.Persistenceundersrc/Shared/Persistence/.