Linear Algebra and Geometry#

Status: implemented — std/linear and std/geometry exist and their tests pass natively; section 12 lists the language gaps that remain (item 12 is still open) and section 13 the packages that come after.

One vector type per width, over whatever number the program counts in. This is the specification of std/linear and std/geometry, of the scalar tower they stand on, and of the rule that decides which member exists for which scalar. Nothing here is a new feature of the language: the types are ordinary types, the layering is ordinary conditional extend, and the operators are the traits they already were.

            Numeric            Signed              Real                      the bound
  + - * / % == <          unary -, absolute    squareRoot, sine, ...       what it adds
  ────────────────────────────────────────────────────────────────
  dot, lengthSquared      negate, absolute     length, normalized          Vector2, Vector3, Vector4
  cross, min, max         manhattanLength      angle, rotated, lerp
  Rectangle, Box          perpendicular        Circle, Sphere, Ray, Plane
  ────────────────────────────────────────────────────────────────
  Int, Int32, UInt, ...   Int, Int32, Float    Float64, Fixed              who carries it

1. The scalar tower#

There is one Vector2, and its scalar is a type parameter with a bound and a default.

public type Vector2<Scalar: Numeric = Float> with Add, Subtract, Multiply<Scalar>, Divide<Scalar>

Vector2 is the float vector, Vector2<Int> the pixel or tile vector, Vector2<Fixed> the deterministic one. There is no Vector2i and no Vector2Int: without macros a twin type is a hand-maintained copy, and an abbreviation in a name is not a name.

What each instantiation can do is decided by three bounds, and the bounds are the library's whole structure:

BoundDeclared inWhat it addsWho carries it
Numericstd/number+ - * / %, ==, <, Show, TryFrom<String, _>every integer type, Float32, Float64, Decimal, Fixed
Signedstd/numberunary -, absolutethe signed integers, the floats, Fixed
Realstd/number (new)the constants pi, tau and epsilon, squareRoot, the eight trigonometric functions, floor/ceiling/round, halved, the two degree conversionsFloat64, Fixed

Real is the line this library is built along. Everything that is arithmetic alone — dot, lengthSquared, cross, min, max, clamped, a Manhattan length, a rectangle intersection, a tile lookup — lives under Numeric, so a grid vector has it. Everything that needs a root or an angle — length, normalized, distanceTo, angle, rotated, interpolation, circles, matrices with rotations, quaternions — lives under Real, so a grid vector cannot reach it and never silently rounds.

The full shape of Real:

public trait Real with Signed {
  static pi: Self
  static tau: Self
  static epsilon: Self
  fn squareRoot(): Self
  fn sine(): Self
  fn cosine(): Self
  fn tangent(): Self
  fn arcSine(): Self
  fn arcCosine(): Self
  fn arcTangent(): Self
  fn arcTangentDivided(by: Self): Self
  fn floor(): Self
  fn ceiling(): Self
  fn round(): Self
  fn halved(): Self
  fn radiansOfDegrees(): Self
  fn degreesOfRadians(): Self
  fn doubled(): Self { self + self }
}

Three of those need a word.

halved and doubled are not mathematics, they are a workaround, and they are named so that they read as members rather than as a hack. A body that is generic over its scalar cannot write a numeric literal at all: a literal has a type, and inside extend<Scalar: Real> Vector2<Scalar> that type is Scalar, which no back end substitutes (see section 12). So size / 2 cannot be written, 1 cannot be written, and the midpoint of a rectangle, the bottom row of a transformation matrix and a "full" interpolation factor all need one of the two constants. Both are answered now. A literal adapts to the parameter (item 14 of section 12), and Numeric requires static zero: Self and static one: Self of every number type (item 10), so a generic body writes Scalar.zero and Scalar.one - the free function zeroOf(value) that derived the zero as value - value is deleted, and so is the member unit() that asked a value of the scalar for its one: every caller reads Scalar.one.

radiansOfDegrees and degreesOfRadians are members of the scalar and not functions of std/linear for the same reason: the conversion needs the value of pi in this scalar's type, and 180 cannot be written in a generic body either. Each implementor carries its own exact factor, which for Fixed is a precomputed count of parts rather than a division.

Real requires the constants pi, tau and epsilon (static pi: Self, section 12 item 10), under the names the scalars already carried them by, so nothing was renamed: Float64.pi and Fixed.pi, Float64.tau and Fixed.tau, and epsilon, the gap between one and the next value the scalar holds - two to the power of minus 52 for a Float64, one part (Fixed.step) for a Fixed. tau is its own constant and not pi.doubled(), because it is rounded once in the scalar's type: for Fixed the two differ by a part. A generic body reads Scalar.pi, so the turns of Angle are one declaration for every scalar and Circle.area and circumference read Scalar.pi and Scalar.tau; std/geometry's halfTurnOf, which took arcCosine of minus one because nothing else could name pi, is deleted.

2. The types#

std/linear:

TypeFieldsDefault scalarBound
Fixedparts: Int64—carries Real
Angle<Scalar>radiansFloatReal
Vector2<Scalar>x, yFloatNumeric
Vector3<Scalar>x, y, zFloatNumeric
Vector4<Scalar>x, y, z, wFloatNumeric
Matrix2<Scalar>xAxis, yAxis (each a Vector2)FloatNumeric
Matrix3<Scalar>xAxis, yAxis, zAxis (each a Vector3)FloatNumeric
Matrix4<Scalar>xAxis, yAxis, zAxis, wAxis (each a Vector4)FloatNumeric
Quaternion<Scalar>x, y, z, wFloatReal

std/geometry:

TypeFieldsBoundNotes
Rectangle<Scalar>origin, sizeNumerichalf-open
Circle<Scalar>center, radiusRealclosed
Segment2<Scalar>from, toNumericboth ends belong to it
Ray2<Scalar>origin, directionRealhits answer a distance
Triangle2<Scalar>first, second, thirdNumericwinding not fixed
Polygon<Scalar>corners: List<Vector2<Scalar>>Numericconvex tests first
Box<Scalar>origin, sizeNumerichalf-open
Sphere<Scalar>center, radiusRealclosed
Ray3<Scalar>origin, directionRealhits answer a distance
Plane<Scalar>normal, distanceRealnormal of length one
Triangle3<Scalar>first, second, thirdNumericnormal needs Real

Matrix2 is in. It is the linear part of a 2D transformation — rotation, scale, shear, no translation — it is what Matrix3.affine takes and Matrix3.linearPart answers, and it costs two vectors.

A matrix stores its columns as vectors, not as a flat array. Array<Item, const Size: Int> was the alternative and a probe settled it: Array is implemented by no back end — stage 0's interpreter answered Unknown name Array while it existed, and the C back end's manifest marks every member of it planned — so nothing that uses one runs today. Beyond that, columns as vectors is the better design: a column is where a basis vector lands, so matrix.xAxis is the answer to a question a caller actually asks, matrix.applied(to: unitX) and matrix.xAxis are the same value, and every column operation is vector arithmetic that already exists. at(row, column) is there for the loop that wants it, and column(index) and row(index) for the two directions.

3. Which member lives under which bound#

Short form; the docblocks are the full list.

Under NumericUnder SignedUnder Real
add, subtract, multiply, divide (by a scalar)negatelength, distanceTo, distanceSquaredTo
dot, lengthSquared, crossabsolutenormalized, withLength
scaled(by:), divided(by:) (component-wise)manhattanLength, manhattanDistanceToangle, angleTo, rotated, rotatedAround
min, max, clamped, withX/withY/withZperpendicular (2D)interpolated, projectedOnto, reflected, isCloseTo
filled, isZero, sum, largestComponent
Matrix.transposed, determinant, multiply, applied(to:), at, column, row, scaling, affine, translation, transformedPointMatrix.negateMatrix.rotation, inverse, inverseAffine, isCloseTo
Rectangle/Box: contains, encloses, intersects, intersection, combined, covering, translated, grown, closestPoint, bounds, area/volume, isEmptymanhattanDistanceTocenter, centered, distanceTo, distanceSquaredTo
Segment2: step, sideOf, bounds, intersectsmanhattanLengthlength, direction, center, at, closestPoint, distanceTo, intersection
Triangle2: signedArea, bounds, edges, contains, translateddoubledAreaarea, center, closestPoint, distanceTo
Polygon: edges, signedDoubledArea, bounds, isConvex, contains, containsConvex, intersectsConvexdoubledAreaarea
Triangle3: firstEdge, secondEdge, doubledAreaVector, boundsnormal, area, plane, center, weightsOf, contains
— (the whole type needs Real)Circle, Sphere, Ray2, Ray3, Plane, Quaternion, Angle

One entry in that table is a compromise and says so in its # Open:

  • Rectangle.center needs Real because it halves, and the midpoint of a whole-number rectangle is not a whole number. The library does not pick a rounding for a program.

4. Naming#

  • Full words. squareRoot, not sqrt. lengthSquared, not lengthSq. arcTangentDivided(by:), not atan2 — and it takes a label, because y and x in that order is the one signature everybody gets wrong.
  • A method that answers a changed copy is a participle or a noun: normalized, rotated, translated, grown, transposed, floored, rounded, clamped, interpolated, combined, covering, flipped, mirrored. perpendicular, determinant, bounds, center, area, ceiling are nouns.
  • A question is is.../has...: isEmpty, isZero, isConvex, isInFront, isCloseTo.
  • A transition between scalar kinds is explicit and named after its target: Vector2<Int>.toFloat(), Vector2<Int>.toFixed(), Vector2<Float>.rounded(), .floored(), .ceiling(), Fixed.toFloat(), Fixed.toInt(), Fixed.approximating(aFloat). There is no implicit conversion anywhere.
  • Segment2 carries the digit that Ray2/Ray3 and Triangle2/Triangle3 carry, because a segment of space has no word of its own the way Rectangle/Box and Circle/Sphere have one. A Segment3 therefore lands beside it rather than renaming it.
  • A conversion is never given the same name in two instantiations of one type. toFloat exists on Vector2<Int> and nowhere else, toFixed on Vector2<Int> and nowhere else, rounded/floored/ceiling on Vector2<Float> and nowhere else. The reason was stage 0's interpreter, which had no types and therefore could not tell extend Vector2<Int> from extend Vector2<Fixed>; two toFloats would have made one of them silently answer the other's arithmetic. Stage 0 is gone and the VM of milestone 7 reads the typed IR, so the trap is gone with it; the names stay, because each one says what it converts to. The constants (zero, one, unitX, unitY) are one declaration for every instantiation, over Scalar.zero and Scalar.one.

5. Literals and inference#

Vector2(1, 2) is a Vector2<Int>; Vector2(1.0, 2.0) is a Vector2<Float>. The literals decide, through ordinary inference, and the two do not mix:

const tile = Vector2(3, 4)          // Vector2<Int>
const step = Vector2(3.0, 4.0)      // Vector2<Float>
const exact = Vector2(Fixed.from(3), Fixed.from(4))
const crossed = tile.toFloat().add(step)

Three inference rules are worth knowing, and two of them are sharp edges:

  1. A literal adapts to a declared scalar. const size: Vector2<Float> = Vector2(1, 2) works, because the annotation says Float64 and 1 adapts to it.
  2. A constant takes its scalar from the expected type, and from the declared default where nothing expects one. The constants (zero, one, unitX, ..., identity) are declared once, for every scalar, over the scalar's own Scalar.zero and Scalar.one: const tile: Vector2<Int> = Vector2.zero is the grid's zero, Vector2<Fixed>.zero the deterministic one, and a bare print Vector2.zero the Float one.
  3. A const binding of a generic result may need an annotation. Inside extend<Scalar: Real> Vector2<Scalar>, const size = self.length() does not infer, and const size: Scalar = self.length() does. A result position infers fine; only a bare binding does not.

6. Operators#

The operators are the traits they already are, and the vectors and matrices come with them:

WrittenTraitOn
a + bAddvector + vector, matrix + matrix, angle + angle
a - bSubtractthe same three
-aNegatea vector, a matrix or a quaternion of a Signed scalar, conditionally
a * sMultiply<Scalar>vector times scalar, angle times scalar
a / sDivide<Scalar>vector by scalar
a * bMultiplymatrix times matrix, quaternion times quaternion — the composition
a == bEqualsevery type, generated from the fields
a < bCompareFixed and Angle

Negate is the pattern worth copying: it is not on the type, it is a conditional implementation extend<Scalar: Signed> Vector2<Scalar> with Negate, because Vector2<UInt> has no unary minus and saying so in the bound is better than a body that panics.

Three products are methods and not operators, each for a reason:

  • matrix.applied(to: vector). A type has one namespace of members, and multiply is already the composition of two matrices. Two Multiply implementations with different Other are legal as declarations and coherent, and neither the member lookup at a call nor the operator resolution finds the second one — so writing both would be an API nothing can reach. applied(to:) also reads better in a chain: projection.multiply(view).applied(to: point).
  • vector.scaled(by: otherVector) for the component-wise product, because * on two vectors means the dot product to half of the world and the component-wise product to the other half, and neither half is wrong.
  • scalar * vector is not provided. It would be extend Float64 with Multiply<Vector2<Float64>, Vector2<Float64>> in std/linear. The orphan rule allows it (the package owns Vector2, a type named as an argument of the trait), and the operator form lowers as that implementation since #440. It is a library choice: every numeric package would reach into Float64 with an implementation of its own. Write vector * scalar.

In a compiled program the operator symbols on these types do not work yet, because the back end does not lower an operator whose implementation belongs to a generic type. The trait method is the same call and does work (a.add(b), a.multiply(factor), a.negate()), the checker and the interpreter accept both, and the native gate programs are written in the method form for exactly this reason. It is the first item of section 12.

7. Conventions#

Nothing here knows which way is up. y is the second component and z is the third. A program that draws with y growing downwards and one that draws with y growing upwards use the same vectors, the same rectangles and the same rotations; only what a viewer sees differs. Concretely, the library never has a member called up, down, left, right, top or bottom, and Rectangle says minimum/maximum and origin/size rather than top/bottom.

QuestionAnswerWhy
Handedness in 3Dright-handed: unitX.cross(unitY) == unitZthe convention of mathematics, of OpenGL's classic pipeline and of glam/nalgebra/cgmath; a left-handed world is one negated axis away
Vectorscolumnsmatrix * vector, so a composition reads right to left as it does in mathematics: a * b is "a after b"
Matrix storagethe columns, each as a vectora column is where a basis vector lands; this is also what a graphics API means by "column-major", so a buffer upload is the fields in order
Anglesradians, always, wrapped in Angleone unit inside, and the two places where the other unit appears are Angle.degrees and toDegrees
Rotation directionfrom the first axis towards the second: a quarter turn takes unitX onto unitYit is the only statement that does not need to know which way y points; whether a viewer calls it clockwise is the program's own business
Rectangles and boxeshalf-open: the minimum edge is inside, the maximum edge is outsideso that a row of them tiles: Rectangle(Vector2(0, 0), Vector2(8, 8)) and Rectangle(Vector2(8, 0), Vector2(8, 8)) share no cell, and a hit test never answers twice
Circles and spheresclosed: the boundary is insidethey do not tile, so nothing is lost by counting a boundary twice, and a great deal is lost by leaving a touching point out of both
Bounding volumesclosed hullsbounds and covering answer the box whose corners hold the shape, so a corner can lie exactly on a maximum edge and contains then answers false for it. A bound is a bound; the half-open rule is about tiling
Raysa hit answers how far along the ray, not the pointthat is what a caller compares to find the nearest of several hits; ray.at(distance) turns it back into a point

8. Numeric policy#

  • Overflow panics, here as everywhere in the language. lengthSquared on a Vector2<Int> squares both components, so a pair of coordinates above about three billion leaves the range of an Int although the vector itself is ordinary. That is a bug in the program and the panic says so. Fixed multiplies before it scales back down, so its real limit is about 46341 and not Fixed.largest; both are in the docblocks.
  • Integer division truncates towards zero and a division by zero panics, because that is what Divide says. Rectangle<Int> therefore has no center.
  • Floats are compared with a tolerance. Float64.isCloseTo(other, tolerance:) already exists in std/number with a default of 0.000001, and every shape and vector here carries isCloseTo(other, tolerance:) with the tolerance required — a tolerance that is right for a unit vector is wrong for a world coordinate, and a default would hide which one was meant. Fixed.isCloseTo defaults to 66 parts, about a thousandth.
  • A zero-length vector normalizes to itself rather than panicking or answering nan. There is no direction to keep, and the honest answer is the one the caller can test.
  • No nan reaches a comparison here. Float64 has a total compare with nan above everything, and Fixed has no nan at all — which is one more reason a lockstep simulation runs on it.
  • arcSine and arcCosine panic outside [-1, 1], in both implementors of Real (decided 2026-09-26). Fixed cannot answer nan, and Float64 used to answer C's nan there, so a body generic over the scalar stopped for one and computed on with a poisoned value for the other. The precondition is an ordinary expression (value.absolute() <= Scalar.one), which is exactly where PANICS.md's principle allows a panic and where a wrong number that keeps running is the worse answer (its variant E). Float64.arcSine is therefore TorbScript over arcTangentDivided with the check in front, nan still answers nan, and value.clamp(-Scalar.one, Scalar.one) is the total form for a ratio that rounding pushed past one. The other members where the two differ - squareRoot and naturalLogarithm below their domain - are "what the implementor documents" in Real and stay so.
  • Fixed is bit-identical everywhere. Every operation on it, the square root and the trigonometry included, is integer arithmetic. The native gate programs linear.trb, geometry.trb and grid-vectors.trb compare the compiled binary against their .expected byte for byte (they compared stage 0 against the binary until stage 0 was deleted), the VM joins that comparison in milestone 7, and that is the guarantee written down as a test.

9. What is deliberately not in#

  • No SIMD intrinsics and no alignment padding. Vector4<Float> is four doubles and nothing else. A Vector4 that was secretly 32 bytes wide would make Vector3 a lie, and vectorization is the back end's job — the C back end can recognise the loops, and until it does the cost is honest rather than hidden.
  • No swizzles. There is no .xy, .xzy or .rgba. Each of them is a name per permutation (fifty for four components) with no way to generate them, and Vector2(v.x, v.z) says the same thing in the same number of characters. toVector2(), toVector3() and withX/withY/withZ cover what is actually used.
  • No implicit conversions. Vector2<Int> and Vector2<Float> do not mix, and no arithmetic widens on its own.
  • No From between two instantiations. extend<Target, Source> Vector2<Target> with From<Vector2<Source>> where Target: From<Source> type checks and does not collide with the blanket Into, which answers the coherence question the plan asked — but the static member of a trait cannot be called through a type parameter, so nothing could reach it. The named conversions (section 4) are also the better API: toFloat() says which direction it goes.
  • No projection matrices. A projection depends on the clip space of the API that consumes it — how deep it is and which way it points — and this package knows nothing about an API. They belong where that convention is known.
  • No triangulation and no convex decomposition. Both are algorithms rather than geometry, both want a scratch buffer, and a mesh library is where they earn their place.
  • No colour. std/color is its own package: a colour is not a vector, it has a space, and Vector4 as a colour is the mistake every graphics library regrets.

10. Fixed, the deterministic scalar#

Q16.16 in an Int64. The field is parts, a count of 1/65536, so Fixed(65536) is one and Fixed(1) is the smallest step. Values run to about ±1.4e14 with a resolution of 1.5e-5.

Why not Q32.32. A Q32.32 multiplication needs (a * b) >> 32 over a 128-bit intermediate, and the language has no 128-bit integer. It can be emulated with UInt64.multipliedWrapping on split halves, which is thirty lines of sign-careful bit work that no test in the repository exercises — for a type whose entire purpose is being trustworthy, that is the wrong trade. Q16.16 multiplies as parts * other.parts / 65536 in one Int64, which is exact until the intermediate overflows at about 46341, and an overflow panics rather than wrapping. A world of 46000 units at 1.5e-5 resolution is a large game.

No bit operations. Every shift is written as a division or a multiplication by a power of two. That is not aesthetics: the interpreter has no Bits member at all (shiftedLeft on an Int answers "has no method"), so a Fixed built on shifts would run in no test and in no gate program. Truncating division and an arithmetic shift differ for negative values, and the library uses division consistently, which is what makes both stages agree.

squareRoot is Newton's method on integers over parts * 65536, which answers the largest value whose square is at most the input — so it is exact for a square and one part low at worst. It panics above about 2147483648, where the scaling up overflows.

sine and cosine are CORDIC: sixteen rotations by a fixed angle each, where every rotation is an addition and a division by a power of two. No table of sines, no series, no floating point. The angle is first reduced into [-pi/2, pi/2] with a sign flip for the cosine, the vector starts at the reciprocal of the rotation's gain (39797 parts) and ends at one, and the sixteen step angles are arcTangent(2^-i) in parts. The divisions round to nearest rather than towards zero, because sixteen truncations pull every vector towards the origin and add up to a visible error; with rounding the answers are within about two parts of the true value, which the tests pin down. arcTangentDivided(by:) is the same machine run the other way: the rotations that drive the vertical component to zero add up to the angle. arcSine goes through it with squareRoot(1 - x²) as the other component, and arcCosine is a quarter turn minus that.

show writes the exact decimal. A Fixed is a multiple of 1/65536, so its expansion always ends, after at most sixteen digits — and Fixed.tryFrom("0.1") shows as 0.100006103515625, because that is the number. parse reads at most nine digits after the point and rounds to the nearest part.

What this buys. Vector2<Fixed>, Rectangle<Fixed>, Circle<Fixed>, Ray3<Fixed>, Matrix4<Fixed> and Quaternion<Fixed> all work with no change to a single line of the generic code — the three native gate programs are the proof, and they are byte-identical between the interpreter and the compiled binary. It also buys the only trigonometry this repository can currently run at all in the interpreter, since Float64's implementation of Real lives in std/number, which the interpreter does not load.

11. Where this comes from#

LibraryWhat it got rightWhat hurtWhat we take
glam (Rust)columns as Vec4 fields; Affine3A separate from Mat4; one obvious type per jobVec3/Vec3A twins for alignment; feature flags decide f32 vs f64; no generic scalar, so DVec3 is a macro-generated copycolumns as vector fields; the affine form as its own constructor rather than its own type
nalgebra (Rust)one Matrix<T, R, C, S> for everything, statically sized; Unit<T> for "this is normalized"the type signatures are unreadable; compile times; a beginner cannot spell the type of a 3-vectorthe idea of encoding "normalized" in the type is noted and not taken: it doubles the API for a property one normalized() call establishes
cgmath (Rust)Rad/Deg newtypes for angles — the bug it prevents is realtwo angle types plus an Angle trait is one too many; InnerSpace/VectorSpace/MetricSpace traits are more layering than payoffone Angle wrapper, radians inside, degrees at the boundary
euclid (Rust, Servo)units as a phantom type parameter, so a Point2D<f32, ScreenSpace> cannot be added to a world point; Point and Vector are different typestwo type parameters on every signature; the space parameter infects every functionthe units idea is the strongest thing on this list and is deferred to std/transform, where a coordinate space is the subject
Godot (Vector2/Vector2i)a first-class integer vector, which is what a tilemap and a pixel actually needtwin types, hand-maintained, and the method sets drift apartthe integer vector is first class here too — as Vector2<Int>, one declaration
Unity.Mathematicsshader-like float2/float3, swizzles, SIMD-backedlowercase type names fight every other convention; fifty swizzle properties; float3 is not Vector3 and the two coexist forevernothing, except the confirmation that swizzles are a cost
Swift simdoperators on tuples-of-floats with hardware backing; simd_quatf separate from vectorsfixed widths only; the type names leak the register widththe quaternion as its own type, never four loose floats
GLM (C++)mirrors GLSL exactly, so shader code and host code read alikeglm::vec3 is float only unless you reach for tvec3; the GLSL mirror brings swizzles and implicit conversionsthe "host and shader read alike" goal, to be paid for in std/gpu by generating the shader rather than by imitating it

Two things nobody on that list has and this design does: one generic type instead of twin types, paid for by the scalar tower rather than by macros, and a fixed-point scalar that carries the same trait as the float, which makes every line of geometry deterministic by changing one type argument.

12. What the language and the compiler must provide#

In the order it hurt, each with a reproduction. examples/generic-scalar/ was the reproduction of the generic ones; since items 2 and 14 closed it builds and runs natively and prints what it should, so it stays as the lines that would say so if they came back. The rest quote the diagnostic that is the reproduction. Statuses re-verified on 2026-09-22 with probes in tests/language/ and a native build of the example.

  1. ~~An operator on a generic type does not lower.~~ Closed. a + b is Add.add, and which add runs is decided by the operand and not by the implementation that declares it: extend<Scalar: Signed> Vector2<Scalar> with Negate names a target that still holds a parameter, while the value in front of the operator carries the enclosing instance's arguments. operandTypeOf in compiler/src/ir/lower/call.trb therefore prefers the written operand wherever the target is not closed, and the operator form and the method form of one call are the same function - inside a generic body as well. tests/conformance/generic-operators.trb is the gate.
  2. ~~A numeric literal in a generic body is never adapted to the parameter.~~ Closed with item 14. doubled in examples/generic-scalar/src/main.trb builds natively and prints 6 3.0. What it said: fn oneOf<Scalar: Numeric>(): Scalar { 1 } type checks, the checker records Int64 for the literal, and the back end then reports "the function returns Float64 and return carries Int64" for the Float64 instance. The substitution is not what is missing - there is no parameter in the recorded type to substitute - so this is the checker's half of item 14, and it closes there: the literal has to adapt to the parameter, and the back end then does with it exactly what it does with const x: Float = 1, which compiles today. Everything in section 1 about halved and the deleted unit and zeroOf existed only because of this. Reproduction: examples/generic-scalar/src/main.trb, doubled.
  3. ~~A const member of a generic type is not instantiated per type argument.~~ Closed in the back end. Box<Int>.empty and Box<String>.empty are one declaration and two values, and a binary keeps one cell per instance, named after the arguments the read decided. Stage 0's interpreter answered the same value for every scalar while it existed, which was item 6 seen from another side and is why the constants of this library live in concrete extends. tests/conformance/generic-constants.trb is the gate.
  4. ~~A type parameter's default is not used to reach a member of a concrete extend.~~ Closed. A bare Pair.zero with Item: Numeric = Float checks and runs natively, printing Pair(value: 0.0). What it said: Vector2.zero where zero is declared in extend Vector2<Float> reports "Cannot infer Scalar of Vector2" even with the annotation const origin: Vector2<Float> = Vector2.zero; Vector2<Float>.zero works. Since the declaration says Scalar: Numeric = Float, the bare name has an answer. Smallest change: apply the declared defaults where a member access on a bare type name has nothing else to go on. Reproduction: examples/generic-scalar/src/main.trb, extend Pair<Float>.
  5. A package may only import what its manifest names as a dependency. use Vector2 from "std/linear" answers Unknown name Vector2 in a package that does not declare std/linear there - which is every package of std/ testing a sibling, and the workspace of tests/conformance/, since none of them declares a dependency at all (a conformance program is one file with no manifest of its own). Everything of std/ they need therefore imports by path, and a std/ package can only call functions from its own directory. That is why std/geometry reaches std/linear as "../../linear/src/vector2"; the copy of zeroOf each of the two packages held is gone with item 10, because the zero is Numeric.zero now.
  6. ~~The interpreter cannot tell two instantiations of one extend apart.~~ Gone with stage 0. Stage 0's untyped interpreter answered Vector2(x: 1.0, y: 0.0) for Vector2<Int>.unitX, because extend Vector2<Float> was declared first. The VM of milestone 7 reads the typed IR and cannot make that mistake; the library still keeps every conversion under a name of its own (section 4), because each name says what it converts to.
  7. A static member cannot be reached through a type parameter. Scalar.zero() inside a generic body answered Unknown name Scalar on stage 0 (gone), and Vector2.from(other) through From reported "a call of a trait member without a receiver" in the back end (not re-verified on 2026-09-22). A constant is reached through a parameter since item 10 closed (Scalar.zero); a static fn through From is what this item still is, and it keeps a From between two instantiations unreachable.
  8. Float32 cannot carry Real yet. Its arithmetic, ==, compare and Show run in both back ends, rounded to binary32 after every operation, and Float32 with TryFrom<Float64, NumberRangeError> narrows a Float64 to the nearest one (both 2026-10-12, issue #35). What is left is Real itself: no squareRoot and none of the angle members exist for a Float32. Smallest change: one extend Float32 with Real over the float functions of <math.h> (sqrtf, sinf, powf, ...), as natives of their own.
  9. Array<Item, const Size: Int> runs nowhere. Stage 0 answered Unknown name Array while it existed, and the C back end marks every member planned. It is the reason a matrix is fields and not storage, and it will be the reason std/tensor waits for Buffer<Item>.
  10. ~~A trait cannot require a constant.~~ Closed (2026-09-23). A trait declares static zero: Self without a value - a requirement every implementation provides as a constant of its own, in the type, in the extend that implements the trait or in any other extend of the type - or with a value, a default an implementation may replace. A generic body reads it through its parameter (Scalar.zero), and the lowering reads the constant of the type the parameter stands for in each instance, or instantiates the default with Self bound to it (reachedConstantOf in compiler/src/ir/constant.trb, the same question providerOfMember asks for a table). Numeric requires zero and one, every number type of std/number and Fixed provide them, zeroOf is deleted from std/linear and std/geometry, and the constants of the vectors, matrices, quaternions and angles are one declaration per type for every scalar. A constant without a value outside a trait is "A constant of the type needs a value"; a static fn does not stand in for a required constant. Real requires pi, tau and epsilon as well, under the names the scalars already used (section 1), and Matrix3.affine, Matrix4.translation and the other affine forms moved from the Real extension down to Numeric, since a one was all they needed. Gates: compiler/tests/traits.test.trb ("A constant a trait requires"), tests/conformance/trait-constants.trb, examples/generic-scalar.
  11. A member-level where clause on a method of a generic type adds nothing. fn manhattanLength(): Scalar where Scalar: Signed inside type Vector2<Scalar: Numeric> reports "Scalar has no member absolute" in its own body, although the documentation describes exactly that form for a trait member. The library uses a conditional extend instead, which is the better spelling anyway.
  12. Two Multiply implementations on one type are unreachable. extend<Scalar> Matrix2<Scalar> with Multiply<Vector2<Scalar>, Vector2<Scalar>> next to with Multiply on the type is accepted and coherent, and then matrix.multiply(vector) reports "Expected Matrix2<Float64>, found Vector2<Float64>" while matrix * vector reports "Matrix2<Float64> does not implement Multiply". Overloading by a trait parameter is one of the two overload forms the decision log keeps, so a call should find it. Partly closed (2026-09-22): the checker now resolves the operator form (scale * vector beside scale * scale type checks), while the method form scale.multiply(vector) is still refused, because one member name reaches the first instantiation (docs/design/COLLECTIONS.md gap 4). Natively the two bodies are two functions since item 16 closed.
  13. Two small checker reports. const size = self.length() inside a conditional extend does not infer the parameter although the annotation form does; and print(x).round() answers "The checker did not work out the type of this expression — this is a bug of the compiler" where it means "Void has no member round".
  14. ~~A type parameter accepts a value of any type at all.~~ Closed. A String or an unadapted Int64 returned as a type parameter is refused ("Expected Item, found String", "Expected Item, found Int64"), and a literal against a Numeric parameter adapts to it. What it said: fn oneOf<Item: Numeric>(): Item { 1 } type checks, and so does fn textOf<Item: Numeric>(): Item { "x" } and fn boundless<Item>(): Item { 1 }. The literal is not adapted to the parameter - the checker records Int64 for it - and the return is then accepted against Item although nothing makes it one. That is the real cause of item 2: the back end has no fact to substitute, and the IR verifier is what catches the program. The checker has to record the parameter as the literal's adapted type and reject the others; what an integer literal means at a user implementor of Numeric such as Fixed is a language question that has no answer yet, so that instance stays a finding of the back end until it has one.
  15. == on a generic type records no member for some instantiations. Vector2<Float>.equals is not found by lookupMember, so recordTraitCall records nothing and the lowering has no resolution to read; Vector2<Int> has one. The smallest reproduction is a generic type plus a concrete extend of it whose body constructs the type. The lowering answers it the way it answers a tuple's structural comparison - it asks dispatchedOn itself - so no program is blocked by it, and the missing record is still a record the checker owes.
  16. ~~Two From implementations on one type collide in the back end.~~ Closed (2026-09-22). The name of a member of an extend whose trait has arguments carries the applied trait as one more owner (symbolPathOf in compiler/src/ir/instantiate.trb), so extend Path with From<String> next to extend Path with From<Name> are two functions, and so are Board * Scale beside Board * Board (tests/conformance/implementation-trait-arguments.trb). What it said: both were one mangled name, Path.from carried no arguments of its own, and the C compiler rejected the second prototype.

13. The package cut for what follows#

std/transform. Transform2 and Transform3 as a decomposed transformation — translation, rotation (Angle or Quaternion), scale — rather than a matrix, because that is the form an animation interpolates and a scene graph edits, and toMatrix3/toMatrix4 is the one-way door to the shader. This is also where coordinate spaces belong: euclid's phantom unit parameter is the strongest idea in the research table, and a Point<Space> that cannot be added to another space's point is worth a type parameter here, where a space is the subject, and not in std/linear, where it would infect every signature. Hierarchies are values: a parent chain is a List<Transform3> folded, and nothing needs an identity.

std/collision. Broad phase (a sweep over bounds, then a grid or a bounding-volume hierarchy over Rectangle and Box), narrow phase (the separating axis test Polygon.intersectsConvex already sketches, and the Gilbert-Johnson- Keerthi distance algorithm for convex shapes in space), and swept tests — a moving Circle against a static world, which is what a character controller is. It answers contacts — a point, a normal, a penetration depth — where std/geometry answers Bool and a distance, and that is the whole difference between the two packages. It is where a trait Bounds and a trait Contains earn their place, because a broad phase wants to hold shapes of different kinds in one list.

std/curve. Polylines, quadratic and cubic Bézier curves, Catmull-Rom and B-splines, arcs; length by subdivision, the point and the tangent at a parameter, and flattening to a polyline within a tolerance. The same curves serve a canvas or an SVG and a motion along one, which is why it is one package and not two. Fixed matters here: a curve followed in lockstep has to land on the same point on both machines. The name is std/curve and not std/path, because std/path is the package of file paths (docs/design/PATH.md) and one word may mean one thing.

std/animation. Easing functions, keyframes, tracks, tweens and a state machine, over a trait Interpolate that Vector2, Quaternion, Angle, Fixed and a colour all carry — interpolated(toward:by:) is already spelled that way in std/linear on purpose, so the trait can adopt the existing members without renaming anything. Time is Duration from std/time, which is a value and needs no clock.

std/color. Color as a value with a space — linear sRGB, sRGB with its transfer function, Oklab, HSL — because a colour that does not say its space is the bug behind every wrong gradient, and a Vector4 as a colour cannot say it. Interpolation happens in a space the caller names, conversion is explicit, and Color carries Interpolate for std/animation.

14. Open questions#

  1. ~~isCloseTo or isNear?~~ Answered by the owner (2026-09-23): isCloseTo, the name std/number already has. The plan said isNear; nothing is renamed.
  2. ~~interpolated(toward:by:) or lerp?~~ Answered by the owner (2026-09-23): interpolated(toward:by:), the full word. lerp is what every other library calls it and an abbreviation this language does not otherwise allow.
  3. ~~Segment or Segment2?~~ Decided: Segment2. Ray2/Ray3 and Triangle2/Triangle3 carry the digit, and a segment of space has no word of its own the way a Box and a Sphere have one.
  4. ~~Should the four vector constants stay on every instantiation?~~ Answered by the owner (2026-09-23): yes, on every number type. They are one declaration per type over Scalar.zero and Scalar.one now, so Vector2<Int>.zero, Vector2<UInt8>.zero and Vector2<Fixed>.zero all exist, and the interpreter trap of item 6 is gone with stage 0.
  5. ~~Should Matrix4 carry a general inverse?~~ Decided: yes. Matrix4.inverse is the general one by cofactors and inverseAffine stays beside it as the short way for the matrices a scene graph is made of.
  6. ~~Quaternion.interpolated takes the straight path and renormalizes, not the constant-speed path along the sphere.~~ Answered by the owner (2026-10-08): interpolated stays the straight path (nlerp), the cheap default trait Interpolate adopts, and Quaternion.sphericallyInterpolated(toward:by:) is the constant-speed form beside it. Measured (a model of the Q16.16 arithmetic and Float64): the Fixed arc cosine is within about 2e-5 of the true angle down to a dot product of 1 - 2^-16, an angle of about 0.0055 radians (0.3 degrees), the smallest it can tell from zero; Float64 is within 1e-9 of the angle down to 1 - 2^-53. The result is within two parts of the true rotation on Fixed and within 1e-15 on Float64, at every angle tried, with no fallback needed except at the exact end: the weights are ratios of sines, so a wrong small angle cancels. The fallback to the straight path is therefore taken only when the sine of the angle is at most Scalar.epsilon, which is a dot product of exactly one after rounding and the one place a division by zero could happen; the dot product is clamped to [0, 1] first because arcCosine panics outside [-1, 1].
  7. ~~Does std/linear belong in the prelude?~~ Answered by the owner (2026-09-23): no. It stays an explicit import, which the plan asked for; a game project imports it in the files that compute with vectors.
Esc