Design rationale · syntax revision 3

Why it looks like this

Every construct in Science was chosen against alternatives that were written down, priced, and in one case argued for and overruled. This page is the argument, not the feature list: for each decision, what the language does, what else was on the table, why this one, and what it costs — because a decision recorded without its cost is a slogan.

Forty design notes in docs/superpowers/design/ are the source. Where a note was later found wrong, or where a decision is still open, this page says so in the sentence rather than in a footnote. The amendments are the most convincing thing in the archive and hiding them would be the one way to make the rest unbelievable.

Design notes 40 Complete Science listings 15 — 14 clean, 1 a deliberate error Of those, built and run 6, output matched LLVM backend stages 0–3 Compiler sciencec 0.0.1

How every listing on this page was verified

Fifteen Science blocks below are whole modules. They were extracted from this page's own source, written to scratch files and run through sciencec check: fourteen report nothing, and the fifteenth is the deliberate borrow error in Diagnostics, which reports exactly the message printed beneath it. Six were also compiled with sciencec build and executed, and the trailing # comment on those six was compared against their real stdout and matches it. Four more blocks are excerpts of a module printed elsewhere on the page or in examples/, and say so in their heading. Three show removed syntax on purpose, carry a grey rail and a caption, and are not valid Science.

A module that only declares types and functions produces no output, so it has no trailing comment; the verdict of sciencec check is stated underneath it instead. One block — the error model — carries an output comment that was derived rather than run, and the paragraph beneath it says which branch produces it and why the block cannot be executed yet. The reason is worth one sentence of its own: the LLVM backend implements the first four lowering stages and refuses everything else rather than lowering a construct no execution test has ever run. That refusal is a design decision, and this is what it says when you hit it.

error[SC0400]: this `sciencec` cannot build a tuple, which has no `Ty -> CgTy` arm: §3.2's C layout applies to one unchanged and the arm is simply not written
  = note: this `sciencec` implements §10's stages 0 to 3 of `codegen-and-linking.md` and some of what comes after — a script body, string literals and `print`, `extern "C"` declarations and the calls to them, the CFG, scalar arithmetic, functions with parameters, records, `choice`s and `match`, `T?`, and drops of values that own nothing — and refuses everything else rather than lowering a construct no execution test has ever run

How this page argues#

Three rules, taken from the design notes themselves, because they are the reason there is anything here to read.

Decision, reason, cost

Every note in docs/superpowers/design/ states its decision, names the alternatives it rejected, and prices what it asks of the others. A note that disagrees with the core spec says so explicitly and gives the reason. That house style is why the archive can be turned into this page at all: the arguments are already written, and selecting them is the work.

An overruled argument is not a refuted one

syntax-revision-3.md §1 draws the distinction and it runs through everything below. An argument that is refuted stops being available. An argument that is overruled is still available — so when a decision was made against the written case, the case stays on the page, and the reader gets to weigh it themselves.

Where another language is better, it says so

Each comparison below is chosen because it illuminates the decision, not because it flatters Science. Several of them end with the other language winning. That is not modesty: a language that has never been beaten by anything is a language whose author has not looked.

def, and the argument that lost#

The function declaration is def. The design note that introduced it argued against it, was not refuted, and lost. This is the best-documented decision in the repository and it is told here the way the note tells it.

def clamp(hits: Int, limit: Int) -> Int:
    if hits > limit: limit else: hits

print(f"{clamp(230, 200)}")
print(f"{clamp(12, 200)}")
# 200
# 12

The rule it broke

Before revision 3, §4.1 of the core spec said: no keyword is an abbreviation. After revision 2 stripped out returns and for each, that clause was the only part of §4.1 still standing, and revision 2 had leaned on it rather than weakened it — it defended -> specifically on the ground that a symbol is not an abbreviation of an English word. def is a word with four letters removed. It breaks the clause on its face.

The note answered the obvious dodge before there was anything to dodge. const was already an abbreviation, so the rule already had one exception. Its §1.3: one exception is an exception and two is a pattern. Whole words, plus a universal symbol, plus const, plus def is not a rule, it is a list, and a list cannot answer a question it does not already contain. The requests queued behind it are elif, impl, str, len, mut, pub, fn — every one with the identical justification.

The measurement that was made

The strongest argument for def was familiarity. The note took that argument seriously enough to count, and the count is the part nobody expected. It counted the spelling across the languages this project's audience actually writes — not across the corpus a model was trained on.

LanguageSpellingIn the audience because
FortranFUNCTIONNumerical kernels that are still in production
MATLABfunctionInstrument control, signal processing, teaching
RfunctionStatistics, bioinformatics
JuliafunctionNamed by the project as competition
JavaScript / TypeScriptfunctionNotebook front ends, plotting, tooling
PHPfunctionIn the count because the count was not curated
PythondefThe dominant dialect of the audience

Six to one against, and the six include the three languages the project names as its competition. The note's conclusion: familiarity, examined, does not favour def. It favours Python, and Python is one dialect of the audience rather than the audience.

The counter-argument that beat it

The count was not disputed and was not answered. It was overruled: the project owner asked for def, was shown the note and its reasons, was told the question had been decided against, and said to make the change anyway. syntax-revision-3.md §1 records that in one paragraph and refuses to dress it up, on the ground that a design record that quietly re-argues itself into agreement with the outcome is worth nothing to the person reading it in a month.

The substantive counter-argument is in the note's own §3.2, written against itself: the code that will be written is mostly written by models, and models' priors are dominated by Python, not by Fortran. The six-to-one count measured humans. It is a real disagreement about which direction of traffic is heavier, and it was decided the other way.

Two things the note got wrong, found by the change rather than by argument

1. define would have fitted the rule, and nobody proposed it. The note rejected define in a list, because function is a noun naming the thing declared, which is what every other declaration keyword in Science is — type, choice, interface. def is the stem of that same verb: on the note's own stated ground it is worse than define, which was the option that fitted the rule and could have been granted without weakening anything.

2. The reserved-word audit checked the wrong audience. It looked for a physicist or a chemist who binds the name def and correctly found almost none. It did not look for a compiler writer. Science intends to compile itself; examples/21_compiler_shapes.science had a field def: DefId and a read binding.def, because a definition table is what a compiler's middle is made of and def is what every compiler calls the thing. It broke on the first run. The field is definition now, and the file says why in a comment that is still there.

What it cost, counted from the commit

ItemCount
Declarations rewritten in examples/ and fixtures384, across 40 files
Declarations embedded in Rust test sources, where no Science tool can reach them264
Snapshots re-blessed116
New diagnostic1 — SC0156
Tests passing afterwards736

The 264 is the number worth looking at. Those are Science programs written as string literals inside .rs files — invisible to the formatter, to a migration tool, and to the parser's own fixtures mechanism. They will be rewritten by hand-equivalent means at every future revision. That is a structural cost of testing a language from inside its own compiler's test suite, and a revision is what made it visible.

What the keyword rule is now, and it is weaker

Three options were on the table and only one is true of the language as it stands. Declare the rule dead and decide by taste was refused: taste does not scale past the first disagreement, and the language has eleven keyword decisions that would each become reopenable. Keep the prohibition with a named exception list was refused because §1.3 already proved a list is not a rule. What is left:

§4.1, restated

A keyword is a whole English word by default. A shortened keyword is not forbidden; it must beat the audience test — count the spelling across Fortran, MATLAB, R, Julia, Python, JavaScript and PHP — and if it is adopted without beating it, it is named in a register, with who decided it and against what. The register has two entries: const, inherited and never argued, and def, argued and overruled.

The cost is exact. The old clause could be checked by looking at the word. The new one cannot — it is empirical, so it is arguable, and an argument is the thing that lost here. §4.1 has stopped being a veto and become an obligation to record. A reviewer meeting the next request has a method, a precedent, and no authority. Whether that is enough will be found out at elif, not here.

function was freed, not reserved

Holding function as a reserved word was the other way to make the migration diagnostic work, and it was not taken. The parser recognises the stale declaration positionally — the ordinary word function followed by a name — which costs one token of lookahead. A reservation would have cost an identifier that the FFI note already binds, because GSL's gsl_function has a field called function. So this compiles:

type GslFunction:
    function: Int

def call_it(f: borrowed GslFunction) -> Int:
    let function be f.function
    function

sciencec check, 0 errors. A field named function, a local named function, and the whole file clean. Writing function where a declaration belongs is SC0156 with a machine-applicable one-word fix — shown in Diagnostics.

be for binding#

let x be 1 introduces a name; x be 2 assigns to one that exists. = is not an operator in Science at all — not for assignment, not for comparison.

def doubled_plus(start: Int, step: Int) -> Int:
    let mutable total be start
    total be total + step
    total be total * 2
    total

print(f"{doubled_plus(0, 21)}")
# 42

What the alternatives look like

Python

total = start
total = total + step
total = total * 2

Go

total := start
total = total + step
total = total * 2

Rust

let mut total = start;
total = total + step;
total = total * 2;

R

total <- start
total <- total + step
total <- total * 2

Go gets the thing that matters most right, and does it in one character. := introduces, = updates, and the two are visibly different at a glance in the middle of a function. Science makes the same distinction — let carries it — and spends two more characters and a word on the operator itself. On line noise alone, Go wins.

What Science buys for that is a different property. = in mathematics asserts equality, and this language's audience reads mathematics; x = x + 1 is a false equation that every programmer has learned to stop seeing. Science spells equality is (see below), so = would have been available and unambiguous — and was still not taken. R is the one language in the audience list that already made this trade, with <-, for the same reason.

Cost. Every model writes =, reflexively, forever. The mitigation is SC0100, which says assignment is written be and carries an applicable fix — and which is, notably, one of only two diagnostics out of fourteen foreign-habit cases that llm-ergonomics.md §2 found already good. The second cost is smaller and real: be reads as a copula, so x be f(x) can be misread as an assertion rather than an instruction, which is the same trap the spec's §4.1 warns about when it says Science stops short of full natural language.

An honest gap in the record. There is no design note arguing be. §4.1's rule decides it — the operation has no universal symbol, so it takes a word — and nothing else in the archive defends it at length. Of the decisions on this page it is the one with the thinnest paper trail.

-> for returns#

The return type follows ->. The first pass at the language wrote returns, in words, and revision 2 reversed it.

def area(width: Int, height: Int) -> Int:
    width * height

print(f"{area(6, 7)}")
# 42

What it replaces — removed, shown only for comparison

Not valid Science · removed in syntax revision 2

function area(width: Int, height: Int) returns Int:
    width * height

The reversal follows from the rule revision 2 substituted for no symbol where a word would do: use a word where the operation has no universal symbol, and the symbol where one exists and everybody already reads it. -> is not an abbreviation of an English word; it is a mark that every reader of Rust, Go, Swift, Kotlin, TypeScript and Python type hints already reads as returns. And it is shorter in the place where signatures get longest.

SpellingCharactersStage
function summarize(self) returns String:40The original English pass
function summarize(self) -> String:35Revision 2
def summarize(self) -> String:30Revision 3

Where Go does it better

Go

func area(width, height int) int {
    return width * height
}

Java

static int area(int width, int height) {
    return width * height;
}

Go writes the return type bare, with no mark at all, and folds repeated parameter types into one name list. For this signature Go is shorter than Science and reads at least as well. Java puts the return type in front of the name, which is the one arrangement that stops working the moment signatures get long — the name, which is what a reader scans for, moves to an unpredictable column.

Cost, and it is subtle. Revision 2 §4 defends -> specifically on the ground that it is not a shortened word — a defence that only works while the language forbids shortened words. Revision 3 removed that prohibition. -> is unaffected as a decision; it is still a symbol every reader already reads. But the form of the argument for it no longer rules anything out, which revision 2's own risk section names: the -> argument is the one revision 3 spent. A second cost is arithmetic: revision 2 had already refunded six characters per signature on the half of the line where signatures get long, so def's five-character case was asking for a second refund on a line already refunded once.

Words, and where symbols stayed#

Equality and inequality are is and is not. Ordering is >, <, >=, <=. Boolean connectives are and, or, not. There is exactly one spelling for each operation, and that sentence is the whole decision.

def verdict(hits: Int, limit: Int) -> Int:
    if hits >= limit and hits is not 0:
        1
    else:
        0

print(f"{verdict(12, 10)}")
print(f"{verdict(0, 10)}")
print(f"{verdict(3, 10)}")
# 1
# 0
# 0
OperationScienceAlso removedWhy the split falls here
equala is ba == b== is a programming convention, not a mathematical one
not equala is not ba != bSame
greatera > ba is above bFour hundred years old; every reader of a paper knows it
at leasta >= ba is at least bSame
and / or / notand or not&& || !No universal symbol: && is C's, not logic's

The argument is about formatters, not about taste

§4.6 of the original spec carried two complete spellings for six operators and justified the duplication as debt worth taking — symbols in a formula, words in prose. llm-ergonomics.md §4.1 then found the debt could not be serviced, using two lines that stood side by side in the project's own corpus at the time:

Not valid Science · the pairing that removed ==

# Both are field accesses compared for equality. Nothing syntactic
# distinguishes them.
self.x == other.x         # F64, so symbols
self.text is other.text   # String, so words

The only thing telling those apart is type information, and a formatter that must run after type checking is not a formatter. Two permanently un-normalisable spellings, in a language whose code is mostly machine-written, mean permanent drift. Removing the duplication removes the problem: there is nothing to canonicalise, and generated code cannot drift because there is one way to write each comparison.

That same argument is reused twice more on this page, and it is worth noticing that it is the same argument each time: it is why lambda was rejected and why comprehensions were rejected. One spelling per operation is not a style preference here; it is a property the formatter has to be able to enforce.

What removing the phrases bought

Nine reserved words removed against two added (interface and null). Gone: at, above, below, most, least, while, methods, returns, try. Three long-standing collisions in the scientific libraries note dissolved at no cost — poly.at(x), the natural name for polynomial evaluation that had been renamed to evaluate; least_squares, at_most and at_least as ordinary identifiers — and the parser's multi-token phrase-assembly code was deleted rather than maintained.

The cost, and the one the notes do not name

The stated cost is unevenness in a mixed expression: if doc.title is not "" and doc.hits >= 10: pairs a word operator with a symbol one. Revision 2 calls that price small and the same unevenness Python has with == and is.

An observation the archive does not make

In Python, is means identity, not equality, and confusing the two is a famous beginner bug. In Science is is equality. Revision 2 §6.1 states the principle that decides cases like this — an unfamiliar word makes a reader look it up, a familiar word used differently makes a reader confidently wrong — and uses it to reject class. No note applies it to is. This page is not claiming the decision is wrong; it is recording that the strongest objection to it is the project's own stated principle, and that nobody has answered it in writing.

Reserved words, and the audit that reopened them

The core spec reserves a block of words for later phases, which costs ergonomics today to guarantee syntax tomorrow:

    reserved-words.md found that the choice had been presented as two options when there are three, and that the middle one had never been on the table. Not reserve everywhere versus make it contextual in declaration position, but reserve it everywhere it could be ambiguous, which is nowhere after a .. After a field-access dot, a member name is the only construct the grammar admits; no keyword can begin anything there. The reservation buys nothing in that position and costs the language its most common attribute access. The fix is one function beside expect_ident, and it would recover x.shape, poly.at(x), a.union(b), values.any(), re.match(text) and frame.import() outright.

    The note then priced what shape, model and tensor are protecting and found it is a hypothesis: neither F1 nor F4 has specified any syntax using those words in lowercase. Types are capitalised — Tensor of (F32, (batch, 768)) — so the types were never at risk; only the lowercase words were, and the lowercase words are wanted by users. broadcasting.md §11.7 has since answered the open question outright: a shape is a type-level list of const expressions, there is no shape Batch: declaration form in F1, and none is wanted.

    Status: still open, and it is the most user-visible unfinished business on this page. Neither the dot rule nor the freeing has shipped. x.shape does not compile today — see Diagnostics for what it prints — and every user of NumPy, PyTorch, JAX, pandas and xarray types .shape within their first ten minutes.

    Indentation blocks#

    A block is a : and an indented body, or a single expression written inline on one line. Spaces only; a tab in the indentation is SC0003 with no attempt to guess a width.

    def band(hits: Int) -> Int:
        if hits >= 100:
            2
        else:
            if hits >= 10: 1 else: 0
    
    print(f"{band(120)}")
    print(f"{band(12)}")
    print(f"{band(1)}")
    # 2
    # 1
    # 0

    The reason given in the core spec's decision table is one line: readability for an audience that is not primarily made of systems programmers, and affinity with the Python-shaped audience. That is a short reason for a large decision, so it is worth setting out what the braces alternative actually gets right.

    Rust

    fn band(hits: i64) -> i64 {
        if hits >= 100 { 2 }
        else if hits >= 10 { 1 }
        else { 0 }
    }

    Python

    def band(hits):
        if hits >= 100:
            return 2
        return 1 if hits >= 10 else 0

    Braces are better at exactly one thing, and it is not a small one: a brace language survives a mangled paste. Copy Rust out of a chat window with the leading whitespace eaten and rustfmt puts it back. Do the same to Python or Science and the program is gone — there is nothing left to recover the structure from. Every indentation language pays this, permanently, and no amount of tooling removes it.

    That cost is also, oddly, the strongest argument for something else in this language. script-mode.md §1.4 uses it in reverse: every snippet a scientist copies from a docs page, a paper's supplementary material or a colleague's message has to be re-indented before it can be pasted into a script — so a language that takes Python's indentation and also requires a wrapper main has taken the cost and refused the benefit. The two decisions are joined, and script mode is where the second half is paid.

    Two rules that are not Python's

    Tabs are an error, not a width. Python resolves a mixture of tabs and spaces by a rule most people could not state. Science refuses the mixture, because a tab's width depends on how the file is displayed, so a file indented with both would mean different things to two readers of the same bytes.

    An inline body ends where the expression ends, not at the newline. In if a: b else: c the then-branch stops at else because else cannot continue an expression. Fitting on one line is a consequence of that rule, not the rule. An inline body may not be a let, which would bind a name nothing could then use — that is SC0109.

    Ranges use .. and not : precisely because : is already spent: it opens blocks, separates match arms, fields, named arguments and bounds. A language that also wrote a[1:5] would be asking one mark to do six jobs, and the slicing note takes that as settled rather than reopening it.

    interface, not trait#

    An interface is a set of requirements a type can satisfy, with optional default bodies. A type declares that it satisfies one with implements. Semantically it is Rust's trait; only the word changed.

    type Doc:
        title: String
        body: String
    
    interface Summarize:
        def summarize(self) -> String
    
        def preview(self) -> String:
            self.summarize().truncate(80)
    
    Doc implements Summarize:
        def summarize(self) -> String:
            self.body.truncate(200)
    
    Doc has:
        def new(title: String) -> Doc:
            Doc(title: title, body: String.new())
    
    def show(item: borrowed any Summarize) -> String:
        item.preview()

    sciencec check, 0 errors. Not built: the backend does not yet lower String fields in a record construction.

    Why not class, which is the familiar word

    In every language that has class — Python, Java, C#, C++, TypeScript, Ruby — a class is a type with data and methods, usually with inheritance. In Science the type with data is type, and the methods attach with has. Calling the interface a class would mean the one word everybody already knows means something different here than everywhere else. The note's sentence for it is the one quoted three times elsewhere on this page: an unfamiliar word makes a reader look it up; a familiar word used differently makes a reader confidently wrong.

    Java / C# / TypeScript

    interface Summarize {
        String summarize();
    }
    
    class Doc implements Summarize {
        public String summarize() { ... }
    }

    Rust

    trait Summarize {
        fn summarize(&self) -> String;
        fn preview(&self) -> String { ... }
    }
    
    impl Summarize for Doc {
        fn summarize(&self) -> String { ... }
    }

    Doc implements Summarize was already the Java and C# phrasing, so interface plus implements is one coherent vocabulary instead of Rust's trait bolted to Java's implements. It also reverses Rust's word order: impl Summarize for Doc names the interface first and the type second, which is why the migration diagnostic for it, SC0130, needs a reordering fix rather than a substitution. Swift's protocol was the alternative worth naming; it is correct and less widely known, so interface won on recognition.

    Where Go is better

    Go

    type Summarize interface {
        Summarize() string
    }
    
    // Doc satisfies it by having the method.
    // Nothing is declared. Nothing is edited.

    Science — excerpt of the block above

    interface Summarize:
        def summarize(self) -> String
    
    # A type satisfies it only by saying so,
    # and only in the crate that owns the
    # type or the crate that owns the
    # interface.
    Doc implements Summarize:
        def summarize(self) -> String:
            self.body.truncate(200)

    Go's interfaces are structural and Science's are nominal, and for retrofitting Go is plainly better. In Go a type written years ago by somebody else satisfies your interface the moment its method set matches, and no file changes. In Science you must write an implements block, and the coherence rule — the orphan rule, Decision 12 of type-checking-and-mir.md — says you may only write it in the crate defining the type or the crate defining the interface. So a third party cannot make somebody else's type satisfy somebody else's interface at all. That is a real expressive loss, taken deliberately: without the orphan rule two crates implement Display for Doc differently and linking decides which one runs.

    Go pays for structural satisfaction in its own way — a type can satisfy an interface by accident, and nothing tells you which types satisfy which. The trade is genuine in both directions; it is listed here on Go's side because retrofitting is the case scientific users hit first, wrapping a library they did not write.

    The error model#

    A function that can fail returns its value and an error. The error type is nullable. ? is a postfix presence test — err? is a Bool, true when the value is not null. Result of (T, E), Option of T and try are gone; Option of T became T?.

    type Config:
        host: String
        port: U16
    
    Config has:
        def empty() -> Config:
            Config(host: String.new(), port: 0u16)
    
    choice ConfigError:
        Missing(String)
        Malformed(String, Int)
    
    ConfigError implements Error:
        def message(self) -> String:
            match self:
                Missing(key): copy_of(key)
                Malformed(detail, line): copy_of(detail)
    
    def copy_of(text: borrowed String) -> String:
        let mutable out be String.new()
        out.push_str(text)
        out
    
    def parse(text: borrowed String) -> (Config, ConfigError?):
        if text.is_empty():
            return (Config.empty(), ConfigError.Malformed("empty input", 1))
        (Config(host: copy_of(text), port: 8080u16), null)
    
    def read_config(path: borrowed String) -> (Config, ConfigError?):
        let text, io_err be read_file(path)
        if io_err?:
            return (Config.empty(), ConfigError.Missing("the file could not be read"))
    
        let config, err be parse(text)
        if err?:
            return (Config.empty(), err)
    
        (config, null)
    
    def main():
        let config, err be parse("")
        if err?:
            print(err.message())
        else:
            print(config.host)
        # empty input

    sciencec check, 0 errors. Not built: the backend has no lowering arm for a tuple return, so the trailing comment here is derived and not executed — the one such comment on this page. It is derivable: parse("") takes the Malformed branch, err? is therefore true, and message's match hands back the detail string.

    What it replaces — removed, shown only for comparison

    Not valid Science · removed in syntax revision 2

    function read_config(path: borrowed String) returns Result of (Config, Error):
        let text be try read_file(path)
        let config be try parse(text)
        Ok(config)

    The same function in five languages

    Go

    func readConfig(path string) (Config, error) {
        text, err := os.ReadFile(path)
        if err != nil {
            return Config{}, err
        }
        config, err := parse(string(text))
        if err != nil {
            return Config{}, err
        }
        return config, nil
    }

    Rust

    // `?` converts through `From`, so both
    // error types need an impl into this one.
    fn read_config(path: &str) -> Result<Config, ConfigError> {
        let text = fs::read_to_string(path)?;
        let config = parse(&text)?;
        Ok(config)
    }

    Python

    def read_config(path):
        with open(path) as f:
            return parse(f.read())
        # failure is an exception; the happy
        # path is the only path written

    Java

    Config readConfig(String path)
            throws IOException, ConfigException {
        String text = Files.readString(Path.of(path));
        return parse(text);
    }

    What each of those gets right

    Rust is better at the thing that matters most, and the design note says so outright. Result makes ignoring a failure a type error. A returned Error? in Science can be dropped on the floor exactly as Go's can. Rust's ? also composes: three fallible calls are three lines, not nine. Revision 2's own §3.2 opens by conceding that it is a taste decision and both models work.

    Python and Java are better at the happy path. Neither interleaves the error path with the computation; the body of read_config is two lines in both, and it is the two lines the function is actually about. Java's throws also puts the failure in the signature, which is the property Go's model has and exceptions usually lack. The price both pay is the one Science is buying away from: the error path is invisible at the call site, so where a failure is handled is not where it happens.

    Go is the model Science took, and the argument for it is readability in sequence: the error path is written where it happens, in the order it happens. For code that is mostly read rather than written — which is the premise of the whole revision — that is worth more than the brevity try bought. Revision 2's risk section is blunt about the company it keeps: Go's error model is the most-criticised thing about Go.

    What Science does that Go does not

    Two things, and both are type-system work rather than syntax.

    Narrowing. Inside if err?: the compiler knows err is not null, so err.message() above needs no unwrapping and no cast; and after an early if err?: return … the value half of the pair is known good for the rest of the function. It is flow-sensitive typing — Kotlin's smart casts and TypeScript's narrowing do exactly this — and without it the model is cast-ridden and, in revision 2's words, lands worse than what it replaces.

    SC0140. A binding of nullable error type that is never tested with ? on any path to a return is a compile error, not a lint. This is what recovers most of what Result was buying, and revision 2 insisted it ship with the feature, not after it. It did not; it has since. Deliberately ignoring a failure is spelled by an underscore prefix, which is the convention every language with this problem converges on, needs no grammar, and is greppable — show me every ignored error is a question reviewers actually ask.

    error[SC0140]: `err` is never checked for an error
     --> load.science:5:15
      |
    5 |     let text, err be load("run.csv")
      |               ^^^ bound here and never tested
      |
      = note: test it with `if err?:`, pass it on, or return it — or rename it to `_err` to say the failure is deliberately ignored

    Why ? is a test and not a propagation operator

    The character was free: the original design had already removed ? as the error-propagation operator, so it now means exactly one thing. It is total — it produces a Bool and cannot return from the function — which is the whole difference from the try it replaced, and it is what makes value? usable in a chain, a field, a condition, anywhere.

    The one place revision 2 made things worse, recorded by the note it broke

    llm-ergonomics.md §2.2 had a row for g()? — a model reaching for Rust's propagation operator. Before revision 2 that was a syntax error. Now it parses, compiles, and means something else: did g() return something non-null. The note's verdict on its own row: this is the one row where the revision made the situation worse — a hard error became a silent change of meaning. A lexer diagnostic cannot catch it; only the type checker can, when a Bool is bound where a value was wanted, and that message will not mention error propagation unless somebody makes it. No code is claimed for it. It is still open.

    The costs, stated

    Verbosity. Three lines per fallible call instead of one. The corpus file that demonstrates absence and failure roughly doubled in length when it was rewritten, and the file says so in its own header comment.

    Conversion is hand-written. try widened one error type into another by calling From invisibly — and no section of the spec ever said that it did, although two corpus files depended on it. That hole closes by construction now, because the caller writes the conversion at the return, where the caller is already writing the value. It is more typing, and it is the reason the invisible call was never specified.

    And inside a chain the mechanism is simply gone. Two fallible steps in one chain produce two error types, and the conversion revision 2 relocated to the caller's return statement has no return statement to live in. collections-and-chains.md §6.3 records this without softening it: that is the one place in this note where the new error model is straightforwardly worse than what it replaced, and it should not be written up as anything else. Three options are visible and none of them is that note's alone to choose.

    Ownership with no lifetime syntax#

    There is no lifetime syntax. The programmer never writes a region. One sentence in §6.1 of the core spec, and the language's single largest claim. region-inference.md is the rest of it, and building the engine both proved the claim and found the question it was asking was the wrong one.

    The case the claim turns on

    A type with two borrowed fields. One borrowed field is uncontroversial — there is nothing to name because there is nothing to relate. Two is where Rust starts making you write things down.

    Rust

    struct Node<'a, T> {
        defs:  &'a DefTable,
        value: &'a T,
    }
    
    // One name on both fields is an
    // ASSERTION: these two outlive the
    // same thing. It is a statement,
    // not an inference — and it discards
    // information, because at every real
    // construction site they may differ.

    Science — excerpt

    type Node of T:
        defs: borrowed DefTable
        value: borrowed T
    
    Node has:
        def new of T(defs: borrowed DefTable,
                     value: borrowed T) -> Node of T:
            Node(defs: defs, value: value)

    The Science side is §4 of examples/21_compiler_shapes.science, quoted from the file rather than written for this page. The whole file, 296 lines of the compiler's own structures, checks clean:

    $ sciencec check examples/21_compiler_shapes.science
    $ echo $?
    0

    examples/21_compiler_shapes.science encodes the real compiler's structures — DefTable and DefId, the Rib scope stack, a Parser holding a borrowed token slice, the two-borrow Node of T, a diagnostics accumulator — and passes with no region written anywhere. Its Node of T gets two region variables and no constraint anywhere relates them. Nobody is asked whether they are the same. A test asserts the absence of the constraint rather than the outcome, because the outcome would also hold under a rule that compared them and got lucky.

    The decision, and why the evidence for it was not safe

    Decision 3. A type gets one region variable per borrowed field, and the relation between them is inferred at every use site rather than declared once at the type.

    The note checked the existing Rust compiler before writing it: seven types carry a lifetime parameter and not one of them carries two. That is real evidence, and it is what made one region is enough look like a safe bet. The note then says it is not a safe bet, and finds the counterexample already in the repository: the FFI note's FftwPlan has two borrowed fields from two unrelated buffers, and its whole argument is that the region engine now knows that the plan borrows both. The same shape recurs in cuSPARSE descriptors, cuDNN workspaces and BLAS batched handles. It is not exotic; it is the FFI case, and FFI is where this project has invested most.

    §5.2's three answers, and the fourth that made them moot

    Rust's elision rules are a convenience over a syntax that stays available when elision guesses wrong. Science has nothing to fall back on, so elision is not a convenience here — it is the whole mechanism, and it has to be total. §5.2 asked what happens when a body does not determine which parameter the return borrows from, listed three possible answers, and said only one survives.

    AnswerVerdict as written
    Pick a default — say, the first parameterRejected. A program's meaning would depend on parameter order and the failure is silent: a function that should borrow from y compiles, and the error surfaces at a call site in another file.
    Infer across the call graph, letting each call site constrain itExpensive, and not always decisive — a function called once has its answer chosen by its only caller, which is inference by accident.
    Refuse the programKept, as SC0340, with the fix being to return an owned value or an index.

    Amendment 1: there is a fourth answer, it is the one that was built, and it makes elision total

    The three above share an assumption that is never stated: that the answer has to be one name. It has to be one name in Rust because the answer is written as 'a, and a lifetime parameter is a name. Nothing here is written.

    The answer the solver produces is the set of parameter regions the constraint graph says must outlive the result, and a caller intersects them. Sound, total, less precise than a hand-written 'a — and never ambiguous, because a set of two is an answer where a choice between two names is not. Removing the syntax removed the problem the syntax was introduced to solve. The question §5.2 asks — which one? — was the wrong question, and it was inherited from a language that had to ask it.

    SC0340 survives with almost nothing to do. Of the four routes to an empty result set, three turn out to be something else. The only program that reaches it is one that cannot return — two mutually recursive functions whose sole path to a result is each other. The note's own biggest named risk was that SC0340 would fire in ordinary code and the no-syntax decision would be unkeepable. It does not fire on any ordinary code that could be constructed for it.

    What it costs, and the archive is unusually frank about this

    Precision. A caller intersecting a set knows less than a caller reading one 'a. The note's closing line on its own victory: nobody has yet found a program where that matters, and nobody has looked hard.

    Separate compilation was traded away without anyone deciding to. Region inference is interprocedural, so a function's signature regions are computed from its body — which means a module cannot be checked without its dependencies' bodies. Rust deliberately put lifetimes in the signature so a crate can be compiled against another crate's interface. Decision 7 resolves the contradiction by serialising an inferred region summary into crate metadata, and the note refuses to be coy about what that is: it is exactly the same information as the lifetime annotations the language refuses to let anyone write. The language's claim is that a programmer never writes a region. It was never that the compiler does not need one.

    The incremental boundary is coarse where it hurts most. Because regions are not annotated, the invalidation unit is the strongly connected component of the call graph, not the function. A mutually recursive descent parser is one big SCC; the Science compiler will contain one. The language's flagship self-hosted program is the worst case for its own incremental recompilation. Type checking, by contrast, invalidates per function body — because signatures are annotated. The two phases need different granularity and the database has to support both; neither note would have noticed this alone.

    Three shapes have no spelling at all. No RefCell, so no shared mutable state. No Rc, so no cyclic ownership and no back-pointer from child to parent. And no arena handing out borrows — fn alloc(&self) -> &T — which in Rust needs UnsafeCell plus a lifetime. Indices answer the first two, and are a better answer than Rc for a compiler regardless. The third is answered by unsafe, and the note names the asterisk rather than removing it: a language that says "ownership verified at compile time, no annotations" and then requires unsafe for an interner has an asterisk on its headline. A later amendment makes it worse: MIR carries no unsafe marker, so an arena is refused and writing unsafe around it changes nothing.

    The region formulation rejects programs that are sound. Regions are NLL — sets of MIR points — not Polonius. The canonical casualty is the get-or-insert shape that every symbol table wants, and that is not a coincidence: it is why the case is in the note and not in a footnote. Polonius separates the loan from the path and accepts it; it costs a Datalog engine inside the compiler. It is recorded as an F2 upgrade that changes no syntax and no signature, only which programs are accepted, and upgrading a checker to accept more is always compatible.

    The caveat on the acceptance test, which the test states itself

    Five of the compiler's six structures are expressible and the sixth is not used by the existing compiler. That is an encouraging result and it must be read with the caveat the example file writes in its own comments: the hardest structure in a compiler was already written index-not-pointer, for resolution reasons, which is the one style with no region question. The test is easier than the general case by exactly the amount its original authors happened to avoid. The interner is the unanswered one: Ident holds an owned String today, so the front end has no interner and the question has never been asked, and the shape it needs is the shape with no spelling.

    borrowed and mutable as words#

    A shared borrow is borrowed T. An exclusive one is mutable borrowed T. Rust spells them &T and &mut T.

    type Reading:
        value: Int
    
    def largest(items: borrowed Array of Reading) -> Int:
        let mutable best be 0
        for item in items:
            if item.value > best:
                best be item.value
        best
    
    def bump(counter: mutable borrowed Array of Reading):
        counter.push(Reading(value: 1))
    
    def main():
        let mutable readings be (Array of Reading).new()
        bump(mutable borrowed readings)
        print(largest(readings))

    sciencec check, 0 errors. Not built: the backend does not yet lower a for loop's iterator temporaries.

    The trade, in one line each

    Rust

    fn largest(items: &[Reading]) -> i64
    fn bump(counter: &mut Vec<Reading>)
    
    bump(&mut readings);
    largest(&readings);

    Science — signatures from the block above

    def largest(items: borrowed Array of Reading) -> Int
    def bump(counter: mutable borrowed Array of Reading)
    
    bump(mutable borrowed readings)
    largest(readings)

    Rust is shorter and the gap is not close: &mut Vec<Reading> is 17 characters against mutable borrowed Array of Reading at 33, and &[Reading] is 10 against 25. & is also genuinely universal — it is C's address-of, and no reader of systems code has to look it up. The English form buys one thing: a reader who has never written Rust can read the signature. For an audience of physicists and statisticians that is the property the project is optimising, and it is why the same audience test that refused fn keeps borrowed.

    The call site, where Science follows Mojo rather than Rust

    Note largest(readings) above: no borrow written. If a parameter is declared borrowed T, the caller writes compare(a, b), not compare(borrowed a, borrowed b). The signature still says borrowed, so a reader keeps the information; only the noise at the call site goes. The core spec's decision table names the precedent directly: Rust rejected this; Mojo adopted it; for this audience Mojo is right. An explicit borrow stays legal where it clarifies, and bump(mutable borrowed readings) above is that case — a call that can write through its argument is worth marking.

    What forgetting this rule cost, measured

    The auto-borrow is in the core spec and type-checking-and-mir.md never mentioned it. The first run of the finished checker over examples/ produced 147 diagnostics, and roughly 60% of them were a borrow the language had told the author not to write. The note's own conclusion is the useful part: a rule the spec states and the design note forgets is worse than one neither has, because the implementation follows the note.

    The same omission repeated one layer down. is dispatches to Eq, whose method takes self — the bare receiver is the shared borrow. So name is "" has a borrowed String and a String in the source text and two Strings in the call. Demanding that the written types agree would report on exactly the borrow the auto-borrow rule exists to remove, so comparisons are checked through borrows. The cost is two places where the checker's tree no longer matches the token stream one to one, which is a cost MIR pays rather than the reader.

    The surface cost the spec declines to hide: a large array assigned to a new name moves, which will surprise every NumPy user once. What ownership buys in exchange is stated in the same section — device memory freed at a point the compiler can name, rather than whenever a GC that knows nothing about GPU pressure gets around to it; in-place updates proven safe rather than guessed, where NumPy's silent view aliasing becomes a compile error; and a random-number generator that is neither Copy nor Clone, so reusing a key — a silent, undetectable bug in JAX — is SC0301, use after move.

    Chains, closures, comprehensions#

    The shape most Science code takes is a chain of method calls, so the language is built to make that read well. The vocabulary is closed: thirty-eight entries, against Rust's Iterator at upward of seventy.

    type Doc:
        title: String
        body: String
    
    def headlines(docs: borrowed Array of Doc) -> Array of borrowed String:
        docs
            .iterate()
            .discard(each.body.is_empty())
            .map(each.title)
            .take(5)
            .collect()

    sciencec check, 0 errors. Not built: the chain vocabulary is resolved but not yet lowered.

    Closures: two forms, and there is no third

    each names the subject implicitly and is for the case where the parameter is mentioned once. giving names it, and is for everything else — more than one mention, or nesting, because a nested each would name a different subject than the one around it.

    Python

    [d.title for d in docs if d.body][:5]
    
    # the same thing without the comprehension,
    # which is why Python grew one
    list(map(lambda d: d.title,
             filter(lambda d: d.body, docs)))[:5]

    Rust

    docs.iter()
        .filter(|d| !d.body.is_empty())
        .map(|d| &d.title)
        .take(5)
        .collect::<Vec<_>>()

    lambda was asked for and refused, on three separate grounds

    1. There is no third mechanism to be. lambda x: x > 5 and x giving x > 5 differ in no property at all — same binder count, same capture, same nesting, same value. Both land on the identical AST node, and their dump snapshots would be byte-identical. For a project that makes AST snapshots a testing layer, that is not an analogy: it is the project's own instrument reporting that the two constructs are the same construct.

    2. The formatter could not canonicalise it — the same argument that deleted ==. Either lambda is canonical somewhere, and the criterion for that would have to be arbitrary; or it is canonical nowhere, and sciencec fmt deletes every occurrence on first run, so the feature exists only in unformatted source and never appears in the corpus a model learns from.

    3. The vocabulary collision is fatal on its own, and it is the reason the word is not even reserved. λ is a primary named quantity in five fields, and in every one of them it lands in binding or parameter position — the two positions the dot rule cannot rescue.

    Fieldλ isA line someone writes
    Optics, spectroscopywavelengthlet lambda be 532<nm>
    Nuclear physics, kineticsdecay constantlet n be n0 * exp(-lambda * t)
    Linear algebraeigenvaluelet lambda, v be eig(a)
    StatisticsPoisson / exponential ratelet lambda be events / interval
    ML, optimisationregularisation strengthdef ridge(x: Matrix, lambda: F64)
    def ridge(lambda: F64, n: Int) -> F64:
        lambda
    
    let lambda be 532.0
    print(f"{ridge(lambda, 1)}")

    sciencec check, 0 errors. lambda is an ordinary identifier today, in both binding and parameter position, exactly as the note requires. The diagnostic it designed to catch the closure shape without reserving the word — SC0135 — is not implemented.

    And the request was for a form longer than both spellings the language already has: lambda x: x > 5 is fifteen characters, x giving x > 5 is fourteen, and the canonical each > 5 is eight. The author's own constraint, quoted in the note in the language it was written in, was that there should not be three ways to write the same thing — which is the objection, found before the note found it.

    Comprehensions, rejected — and the case for them made fairly

    indexing-and-array-literals.md §5.1 does something unusual: it makes the case for the thing it is about to reject, and refuses to undersell it. A comprehension maps directly onto set-builder notation, which is the notation this audience writes in papers. And it is shorter:

    Python

    [x * 2 for x in xs if x > 0]

    Science — one expression

    xs.iterate().keep(each > 0).map(each * 2).collect()

    The note's own arithmetic: twelve tokens against nine, and two of the extra ones are bookkeeping. Its next sentence is the one that makes the rest of the page credible — anyone who says the chain is obviously better for this case is selling something.

    It loses anyway, for the formatter reason again: a comprehension and a chain are two spellings of map-and-filter, no formatter could canonicalise between them, and adding comprehensions one section after deleting a is at least b would be incoherent. Three supporting reasons, in the note's descending order of weight: a comprehension is eager where the chain is lazy, so the beloved form would be the one that always allocates; it does not scale past the short case, covering the easiest third and handing back the rest, which is the worst shape a second spelling can have; and it reads backwards, output expression first, which is why comprehensions do not compose with each other and chains compose trivially.

    What is offered instead is a diagnostic rather than silence, and SC0153 is live — see Diagnostics. The fix is mechanical for the one-source, one-filter, one-expression case, which is the case people write.

    Names that were changed, and names that were not

    ScienceElsewhereReason
    keep / discardfilter / filter(!p)"Filter" is famously ambiguous about direction — a coffee filter keeps the liquid, an air filter keeps the dirt. Two opposite verbs are unmistakable, and the negation leaves the predicate.
    batches / windowschunks / windows"Chunk" does not say whether the pieces overlap. The contrast now carries information instead of folklore.
    numberedenumerate"Enumerate" means four different things to four audiences.
    minimum / maximummin / maxThe abbreviation rule with no exception carved out for brevity. The note flags it as the entry most likely to be argued about.
    mapmapKept. The best-known combinator name in computing, and this audience already writes it in Python, Julia, JAX and R. transform buys prose and spends the one name everybody already has.
    zip, reduce, collectsameKept on familiarity. reduce in particular is this audience's own word, from MapReduce and from every array library they use.
    union, intersectionsameKept. Mathematical terms for a mathematical audience; renaming them to shared_with and without would be prose for its own sake.

    Pairs are records, never tuples. Rust yields a tuple and you write .0 and .1; Science yields a named record, because each.key works with the implicit-subject closure and each.0 does not. That is what keeps each usable across the whole vocabulary instead of across only the unary half of it.

    Indexing#

    a[i] indexes; m[i, j] is a single index of arity two, not m[i][j]. Indices are zero-based. Every index emits an obligation the compiler tries to discharge, and when it cannot, it says so.

    def scale(values: mutable borrowed Array of Int, factor: Int):
        for i in 0..values.len():
            values[i] be values[i] * factor

    sciencec check, 0 errors. Not built. This is also the syntax §6.5 of the core spec promised and never supplied — that section defends ownership by pointing out that Futhark needed uniqueness types solely to let a pure language write x[i] = v in place safely, and the spec was selling a feature it could not spell.

    The decision that is actually distinctive

    Every a[i] emits an index obligation: 0 <= i and i < extent(a). If the compiler discharges it, the compiled code contains no bounds check and the expression cannot fail. If it cannot, it inserts a runtime check and reports that it had to.

    That last clause is the whole design. Rust and C++ both make the same choice and both make it silently: you cannot tell by reading a[i] whether it compiled to a load or to a load with a branch, and you certainly cannot tell whether it can abort your six-hour run at hour five. Science's premise is that the compiler tells you what it proved. The discharger is a closed list of four rules — a constant extent, a loop over 0..a.length(), a narrowed if i < a.length():, and shape equality in F1 — deliberately not a solver.

    Two alternatives were rejected and both deserve naming. Make an undischarged index a type error is the purist reading of the verification thesis and it is unshippable: F0 has no static shapes, so a[i] would be illegal in the majority of first-release programs, and a feature that is a compile error in the common case is not a feature. Make a[i] always return T? is honest and total and nobody will write it — a[i]? * b[j]? in an inner loop is not a notation a physicist will accept, and it is redundant, because that is what get already is.

    Zero-based, and the reason is a membrane

    The usual arguments — pointer arithmetic, Dijkstra on half-open intervals — are all true and none is decisive. This one is. The adoption strategy is built on one sentence: a person with a working NumPy program can replace one function with a Science one and get the speed without a copy. The mechanism is DLPack, and the tensor layout is already committed to it as an ABI promise. Put one-based indexing on the Science side of that membrane and the two languages, looking at the same bytes with no marshalling step where an index could be adjusted, disagree about which element is [0] forever. The disagreement would be silent, invisible to the compiler, invisible in review, and numerically plausible — a spectrum shifted by one channel, a batch off by one row, a time series lagged by one sample.

    The cost, and the note refuses to minimise it. A physicist transcribing an algorithm from a paper is reading one-based mathematics: summations from 1 to n, quadrature nodes, band indices, Fortran library documentation. Every transcription gets a -1 sprinkled through it, and each one is a place a bug can hide. Julia chose one-based knowingly, over loud objection, and it was not a mistake for Julia's audience. There is no cheap mitigation and the note does not pretend otherwise — offset-configurable arrays fragment every library that touches them and would make the index obligation unprovable in general. Zero-based serves the NumPy audience at direct cost to the Fortran/MATLAB/Julia one; the two conflict, and the project chose the side the adoption strategy is built on.

    Negative indices are an error, not the last element. Python's a[-1] is loved and it is the trap this language cannot afford: a[i - 1] in a loop is correct at every i except 0, where Python silently reads the last element instead of failing. It also makes the lower half of every index obligation undischargeable in principle. SC0152 catches it with .last() as the fix, and it is live today.

    Script mode#

    The top level of a file is a sequence of items and statements, in source order. A file's top-level statements are the body of an implicit def main() -> Error?. This reserves no new words, adds no statement form, and adds one alternative to one production.

    let threshold be 10
    let kept be band(12, threshold)
    print(f"{kept}")
    
    def band(hits: Int, limit: Int) -> Int:
        if hits >= limit: 1 else: 0
    # 1

    Note that band is called above where it is declared. That is not a new rule: the resolver collects every item's definition before a single body is looked at, so items have always been order-independent. The practical consequence is the one the audience wants — a script's helper functions can go at the bottom, where the reader reaches them after the thing they are helping with, instead of at the top where MATLAB and C put them.

    LanguageStatements at top levelEntry pointImport runs them
    Pythonyesfirst statementyes
    Ryesfirst statementyes (source)
    Juliayesfirst statementyes (include)
    MATLAByesfirst statementn/a — but two file kinds
    Gonofunc mainn/a
    Rustnofn mainn/a
    Scienceyesimplicit, entry file onlyno — error

    The four languages the audience actually uses all say yes; the two that say no are systems languages whose users are not this audience. MATLAB is the row not to copy, and §9 of the note argues that seriously rather than dismissing it: a second file kind gets four real things right, including that it is impossible to accidentally write a four-hundred-line script. It loses because the two grammars do not stay apart — a script file needs type for its schema, implements, use and a helper def within twenty lines of real work, at which point the second grammar is the first grammar plus statements, reached through a file extension that bought nothing.

    The line count is not the argument, and the note says so first

    Measured honestly against a real CSV-reading program, the saving is one line and one indentation level. Three things are the argument instead. Concept count before the first program: to print a number, a mandatory main requires the reader to already know what a function is, what main means, why it is called that, and why the whole body is indented under it — four concepts the program is not about, and a scientist who has written MATLAB for fifteen years has needed none of them. The indentation, which in an indentation-delimited language is not cosmetic: see above. And the schema is already four lines and is irreducible, so overhead that is not buying anything should go, because the overhead that is buying something is already at the audience's tolerance.

    The cost: functions cannot read script bindings

    A function declared below a top-level let may not read it. Three reasons, and the third is not taste. It would make item bodies depend on execution order, so a function called before line 1 has run would read an uninitialised binding — every language that allows this pays for it with an initialisation-order rule, and Science can simply not have one. It would put a hole in local inference, because one function's inference would depend on another's inferred local. And it inverts the analysis order the region engine depends on: reverse topological order means callees before callers, so the function is analysed first, but its binding's region is a fact about the script body's MIR, which has not been analysed because it is the caller. That is a dependency cycle across the call graph, and the SCC fixed point does not cover it, because it is not a call cycle.

    Python is better here and the note says so in its own margin — its serves-whom line for this decision reads programmers, at the scientist's expense. A Jupyter user defines a function that reads a notebook variable every day of their life, and this forbids it. The direction is what makes it tolerable: relaxing this later is source-compatible; tightening it later is not. The line is drawn somewhere a user can hold in their head — closures see the top level, named items do not — because a closure inside a top-level statement is an ordinary capture of an ordinary local.

    Importing never runs anything. Python, R and Julia all run an imported file's top-level code, and Python's if __name__ == "__main__": exists solely to work around it. The deeper objection is that it makes import an effect — use data.csv could read a file, allocate, or panic — and a language whose bet is verification should not have a statement whose observable behaviour is invisible at its use site. Statements in a module reached by use are SC0213, an error rather than dead code, because the user wrote code that never executes and the compiler knew.

    What shipping it settled — the estimate was wrong in the cheap direction

    The note costed a two-variant enum in the module type, a resolver change across two walks, an HIR body, and churn in every AST-dump snapshot containing a module header. Every one of those was zero. Exactly three snapshots moved. It shipped as a desugaring: the parser gathers the top-level statements and appends one generated def main() -> Error?, so no phase below the parser learns the word "script".

    The honest price of that zero: generated nodes have no source text, so their spans are zero-width, and the rendering rule the note asks for — a borrow error must say the end of the script, never the end of main, to a user who never typed the word — is still owed. sciencec tools --json --all-functions on a script now lists a main nobody wrote; it is pinned by a test rather than filtered, because filtering it means teaching sciencec about scripts, which is exactly what the desugaring bought out.

    tool as a declaration#

    tool declares a callable exposed to a model — a name, a typed parameter list, a required natural-language description, and a result. It does not mention MCP, it does not name a protocol, and nothing in the language knows what JSON-RPC is.

    ## Fit peaks in a time-of-flight window.
    tool fit_peaks(
            ## The run identifier, as printed in the run log.
            run: String,
            ## Lower edge of the fit window, in microseconds.
            window_lower: F64,
            ## How many peaks to fit at most.
            max_peaks: U8) -> (String, Error?):
        (run, null)
    $ sciencec tools --json fit.science
    [{"name":"fit_peaks","inputSchema":{"type":"object","properties":{
      "run":{"type":"string"},
      "window_lower":{"type":"number"},
      "max_peaks":{"type":"integer","minimum":0,"maximum":255}},
      "required":["run","window_lower","max_peaks"]},
      "description":"Fit peaks in a time-of-flight window."}]

    Real output, reformatted onto several lines to fit this column. The schema is derived from the signature: there is no second copy to drift, and U8 becomes an integer with a stated minimum and maximum because the type said so.

    The problem, which is that Science has no reflection

    Python's SDKs read inspect.signature at run time: a decorated handler carries its parameter names, annotations and defaults as live objects. Science is monomorphised and compiled ahead of time; a function value at run time is a code pointer and nothing else. TypeScript does not introspect either, so its SDK makes the author pass a schema beside the handler — a Zod schema or raw JSON Schema — and nothing checks that the two agree. That is not a bug in the SDK; it is the only thing a language without reflection can do without help from its compiler.

    Python

    @server.tool()
    def fit_peaks(run: str,
                  window_lower: float,
                  max_peaks: int) -> PeakTable:
        """Fit peaks in a time-of-flight window."""
        ...
    # the schema is read off the signature
    # at run time, by reflection

    TypeScript

    server.tool(
      "fit_peaks",
      "Fit peaks in a time-of-flight window.",
      { run: z.string(),
        windowLower: z.number(),
        maxPeaks: z.number().int() },
      async ({ run, windowLower, maxPeaks }) => { ... }
    );
    // two values; nothing checks they agree

    Python's version is better and it is better for a reason Science cannot copy. It is one decorator over an ordinary function, it needs no keyword, and — the part that matters most — it can register tools from data: a directory of scripts, a database table, a set of SQL views. A declaration form is static by construction. The note makes that its first falsifier, with a check anyone can run: across a sample of published servers, how many call a per-tool registration in a loop rather than once per literal tool? If it is most of them, the decision was wrong.

    Why it is a declaration and not an attribute

    A marker that changes nothing about a declaration is an attribute, and Science should not spend a keyword on an attribute. The case rests entirely on whether a tool carries obligations an ordinary def does not. It carries five, and all five are checkable at compile time.

    ObligationBecause
    Every parameter type survives a round trip through JSON SchemaA def may take a closure, a trait object, a borrowed slice; a tool may take none of them
    It may not be generictools/list is a flat list of concrete tools with one schema each
    No parameter may be borrowedThere is no caller to borrow from — every argument was deserialized a moment ago
    It must carry a doc commentThe description is not documentation; it is an input to the caller's dispatch decision
    Its return type is constrainedContent, a record that can produce an output schema, or an Error? alone

    The alternative that was nearly taken is the honest one to name: a record type plus a derive, which needs no keyword at all and reuses machinery two other notes already ask for. It was refused on readability alone — it is two declarations and one indirection where the thing being declared is one callable; a reader must hold FitPeaksArgs in their head to know what the tool takes, and args.run appears in the body where run would.

    The cost, and it is the cost def-and-lambda.md refused to pay

    tool is a second function-declaration form, so every piece of the front end that walks items grows an arm — the parser, the resolver, sciencec fmt, the language server, the doc tool. That is precisely one of the grounds on which def-and-lambda.md refused a second spelling of the declaration keyword. The difference this note claims: a renamed keyword is a synonym for def, and tool is a declaration with five obligations. If that difference is judged insufficient, the fallback is the record-plus-derive, and the note says plainly that it is a good fallback.

    The condition for reopening this is met today

    Decision 1 was taken over the record-plus-derive option on one thing the record form gets for free: per-parameter descriptions. The note pays for it by asking a sibling note's doc-comment attachment table for one extra row, and states the consequence: if that ask is refused, Option B becomes the better answer and this decision should be reopened, because without per-parameter descriptions a tool's schema has a description on the tool and nothing on its properties, which is the one place a model most needs prose.

    Read the emitted schema above again. The three ## runs in the parameter list checked clean and none of them reached the schema: run, window_lower and max_peaks each carry a type and no description. That is the exact state the note names as the one in which the decision should be reopened. It is a gap in the implementation rather than a reversal of the design — but the note wrote the trigger, and the trigger is pulled.

    Diagnostics as design#

    Science has no training corpus and will not have one for years. Every model that writes Science writes it by analogy from Rust and Python, because that is what the syntax rhymes with and what it has read a hundred million lines of. That has an unusual consequence for a compiler.

    The premise the whole error surface is designed against

    The diagnostics are not the fallback channel. They are the only channel. A human learning Science reads a tutorial. A model learning Science reads the error it just got, in a loop, and the quality of that error is the entire bandwidth of the teaching. A compiler that says expected an identifier, found & has told a model nothing it can act on; a compiler that says a shared borrow is written borrowed T, not &T has closed the loop in one round trip.

    A second consequence, less obvious and at least as important: a cascade is actively harmful. A human skims three errors and fixes the first. A model reads three errors as three independent problems and attempts three fixes, two of which are wrong, and the second round trip starts from a worse position than the first.

    That premise turns into three requirements, in priority order, and they are not stylistic. Name the construct, not the token — the message must contain both halves, what the author meant and how Science spells it. Carry an applicable fix — a Suggestion with a span and replacement text is machine-applicable and prose is not. Do not cascade — consume the whole foreign construct and recover as though the correct form had been written.

    What that looks like when it works

    Writing the pre-revision-3 declaration keyword:

    error[SC0156]: the declaration is written `def`
     --> d1.science:1:1
      |
    1 | function summarize(text: borrowed String) -> String:
      | ^^^^^^^^ `function` is not a keyword in Science
      |
      = note: `def` declares every function, method and interface member
      = help: write the declaration as: `def`

    One error, not a cascade: the parser consumes the word and continues as though def had been written, so a stale file produces one message per declaration. The fix is a word for a word, so it is machine-applicable. And the note line teaches scope — def declares every function, method and interface member — rather than a rule about form, because the rule about form is the one def broke. That loss is exact: the diagnostic that was designed as the cheapest place to pre-empt mut, pub and impl now pre-empts nothing, and each of those will need its own row.

    The borrow error, which is where the no-syntax bet is settled

    Rust's borrow-checker messages are the hardest part of that language to read even though they can point at a name the programmer wrote. Science cannot print 'a. Decision 9: a borrow error is reported as a narrative over three spans — where the borrow was born, what keeps it alive, and where the conflict is — and never as a relation between region variables. Here is one, from the compiler, today:

    type Table:
        names: Array of String
    
    Table has:
        def new() -> Table:
            Table(names: (Array of String).new())
    
        def get(self, at: Int) -> (borrowed String)?:
            self.names.get(at)
    
        def add(mutable self, name: String):
            self.names.push(name)
    
    def main():
        let mutable table be Table.new()
        table.add("first")
        let found be table.get(0)
        table.add("second")
        if found?:
            print(found)
    error[SC0330]: `table` is borrowed here and borrowed exclusively before the borrow ends
      --> b2.science:18:5
       |
    17 |     let found be table.get(0)
       |                  ------------ `table` is borrowed here, shared
       |                  ------------ the first borrow is still needed here
    18 |     table.add("second")
       |     ^^^^^^^^^^^^^^^^^^^ ...and borrowed here, exclusively
       |
       = note: a shared borrow and an exclusive one cannot overlap (§6.1 rule 4)
       = note: the borrow ends at its last use, so moving the last use above the conflicting line is enough; binding what is needed before it is the other fix

    No region variable appears anywhere, and none could. §7.2 of the note takes that constraint as a feature rather than a limitation: a named lifetime lets a message say '1 and '2 and leaves the reader to work out what those are; with no names available, the error message is forced to be the narrative, because it cannot be the algebra. That is also why provenance on every constraint is not optional and is the one thing that is cheap now and impossible later: a solver that unions constraints for speed has thrown away the only material an error can be built from, and Science needs the material more than Rust does, because Rust can fall back on printing 'a and Science has nothing to print.

    One honest wart in the output above. Decision 9 calls the third span — the first borrow is still needed here — the load-bearing one, and says it is the last use, which is what makes the region end where it does. In the render above it points at line 17, the borrow's own span, rather than at the print(found) that is actually keeping it alive. The analysis is right and the narrative is not finished.

    The inventory, and how much of it is still true

    llm-ergonomics.md §2 ran fourteen foreign habits through the real lexer and parser and recorded the output verbatim. Two had an applicable fix. Three had the right words and no fix. Nine either mislead or say nothing useful, and eight of the fourteen produce a cascade. The cause of the worst category is structural and the note names it: the English redesign made an item able to start with a bare name (Doc implements Summarize:), so every unknown leading word is now a plausible type name and falls into the implements branch. The old grammar would have rejected fn outright; the new one welcomes it and then complains about the wrong thing.

    The note asks for a block of fifteen migration diagnostics, SC0120–SC0134, to close it. That block is not implemented. Here is what a Rust habit still costs today — one mistake, three messages, and the word borrowed nowhere in any of them:

    error[SC0104]: expected a type, found `&`
     --> d2.science:1:10
      |
    1 | def f(x: &Doc) -> Int:
      |          ^ expected a type, found `&`
    
    error[SC0100]: expected `)`, found `Doc`
    error[SC0100]: expected end of line, found `Doc`
    3 errors

    And a Rust binding habit, which the note files under actively misleading because the compiler's guess is confidently wrong:

    error[SC0100]: expected `be`, found `x`
     --> d3.science:2:13
      |
    2 |     let mut x be 1
      |             ^ expected `be`, found `x`

    mut was read as the bound name. The compiler thinks the binding is called mut — and a model reading that message will believe it.

    Where it already works, because somebody wrote the message

    The difference between the two halves of this section is not architecture. It is whether the message was written. These four were:

    error[SC0153]: Science has no comprehensions; write the chain
     --> L24.science:2:5
      |
    2 |     [x * 2 for x in xs if x > 0]
      |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this bracket holds a comprehension, not a list
      |
      = note: the chain does the same thing, lazily and in reading order: `source.iterate().keep(<condition>).map(<expression>).collect()`
      = note: `keep` comes before `map`, and `each` names the element, so the chain declares no loop variable
    error[SC0152]: a negative index does not count from the end in Science
     --> L25.science:2:8
      |
    2 |     xs[-1]
      |        ^^ an index runs from `0` up to the extent
      |
      = note: Python reads `a[-1]` as the last element, which is what makes `a[i - 1]` in a loop read the last element at an `i` of zero instead of failing
      = help: read the last element with: `.last()`
    error[SC0115]: a nested `each` would name a different subject than the `each` around it
     --> L18.science:5:49
      |
    5 |     docs.iterate().map(each.inner.iterate().map(each.title).collect()).collect()
      |                                                 ^^^^ this `each` is inside another `each`
      |
      = note: name the parameter instead, with the `giving` form: `outer.map(item giving item.inner.map(each.x))`
    error[SC0117]: this file has top-level statements and also declares `main`
     --> L21.science:3:5
      |
    1 | let x be 1
      | ---------- the top-level statements start here, and are already a `main`
    2 |
    3 | def main():
      |     ^^^^ `main` is declared here
      |
      = note: a file whose top level holds statements is a script: the statements are the program, and the compiler writes the `main` that runs them
      = note: move the statements into `main`, or delete `main` and let the statements be the script

    SC0117 deliberately offers no applicable fix, and that is the rule working rather than failing: there are two reasonable fixes — move the statements into main, or delete main — and the compiler cannot choose. It names both. The sibling rule is the one that makes the whole block dangerous: a fix that is wrong is worse than no fix, because it is applied mechanically.

    The risk nobody can mitigate

    The fixes become the training data. If that block of fifteen is good, models will learn Science through it, and the phrasing of fifteen messages will shape how a generation of generated Science reads. They deserve the same care as the syntax itself. And migration diagnostics are not scaffolding for an audience arriving from Rust: they are permanent, because the models will keep arriving from Rust for as long as Rust outweighs Science in the training mix, which is indefinitely.

    Still open, and found wrong#

    The amendments are the most convincing part of the archive, so they get a section rather than a footnote. Everything below was checked against the compiler while this page was written.

    Specified, decided, and not built

    WhatWhat happens today instead
    The migration block SC0120–SC0134 — &T, &mut T, dyn, fn, struct, enum, impl, pub, mutThe cascades the inventory recorded, unchanged. &Doc is still three errors
    The dot rule — accept any word as a member name after .x.shape is SC0102 plus a cascade. Eight reserved words the audit asked to free are still reserved
    SC0135 — recognising the lambda closure shape without reserving the wordA generic two-error parse failure. The word itself is correctly free
    SC0212 — naming a script binding inside a function bodySC0200, cannot find threshold in this scope. The rule is enforced by construction; only the message is missing
    SC0286 — the index obligation the compiler could not dischargeNothing. The checker's own source says so: no index obligation is discharged and SC0286 is not emitted
    Bare return as sugar for return null at the top levelSC0525, expected any Error?, found (). Of the three spellings the note gives an exit code of 0, only return null compiles
    Per-parameter ## descriptions in a tool's schemaAccepted by the parser, absent from the emitted JSON — the trigger to reopen Decision 1
    sciencec fmt canonicalising giving to each where the parameter occurs onceLeft as written. The formatter does reflow the chain onto one link per line

    Decisions that were made and then found wrong

    The atom order for const-expression normal forms. Decision 17 said atoms sort by DefId, assigned in source order, which makes the order stable across runs without a hash. Its own second clause makes the first false: source order within one compilation is the order the files were named on the command line, so sciencec check a.science b.science and the same two files reversed produce different normal forms. Read against the neighbouring item — the normal form is the monomorphisation key — an unstable atom order is an unstable mono key, which emits two symbols for one function, and it would not have been caught by anything, because both spellings are the same syntax. Decision 17 is withdrawn.

    Rule 4 quantified over the wrong thing. The conflict check as first written ranges over borrows, and a program exists with exactly one borrow in it that is nonetheless unsound — a narrowing established after an exclusive borrow, with a write through that borrow invalidating it. A faithful implementation finds no second borrow to conflict with, accepts the program, and the soundness argument for narrowing is silently false. The check ranges over accesses now, of which taking a borrow is one kind.

    A lowering gap does not fail; it goes quiet. The collections note promised that for x in xs: borrows. The compiler lowered it as a move, and nothing reported it for as long as it existed — because a move is refused when it overlaps a live borrow, and lowering the loop as a move meant there was no borrow to overlap. The check was not defeated; it was never given anything to check. The promise was being kept and the rule behind it was absent.

    SC0335 cannot fire. Decision 3 priced an empty region intersection as a named failure needing its own diagnostic. In a solver with no upper bounds, regions only grow from a lower bound that already contains every point the value is live, so the intersection always contains at least the construction point. The situation is real and it surfaces as SC0333 against whichever source dies first, which is a better message than the one that was designed: it names a borrow and the point it stops living, rather than announcing that an intersection came out empty. SC0335 is implemented as a tripwire and has never fired.

    A pre-allocated diagnostic code was in the wrong place. def-and-lambda.md allocated SC0136 as the contingency for exactly the reversal that then happened, and specified the message to the letter. The message that shipped is that message — under SC0156, the next free code in the parser's own migration block, where every sibling already lives. The pre-allocation was not wrong; it was in the wrong place. Both SC0119 and SC0136 went back to the free pool unused.

    Contradictions and stale text found while writing this page

    WhereWhat
    examples/21_compiler_shapes.science §4Still says of two-borrow inference that nobody knows yet, because there is no inference. There is: region-inference.md's headline is that this file's §4 passes with no diagnostic
    type-checking-and-mir.md status lineReads Design. Nothing below is built. crates/science-types does not exist. It exists, and the note's own amendments quote the finished checker's first run
    indexing-and-array-literals.md §1.1 amendmentSays the prelude's Index takes no parameter and IndexMutably does not exist. Both are declared now, with an of Idx parameter
    String.truncateA live contradiction between the corpus and the library note: eighteen call sites read it as -> String, and §6.9 declares it (mutable self, bytes: Int) with no return. Nothing on this page asserts its output
    reserved-words.md §3 versus the lexerThe note recommends freeing eight words in full and §6 removes the hypothesis they were protecting. All eight are still in from_word
    collections-and-chains.md §1.4, sum()Its signature is a bound on an associated type's equality. The prelude's operator interfaces have no associated types, so the bound has nothing to attach to and sum() is not declared

    The three bets that are still bets

    Elision stays total

    No ordinary program has been found that reaches SC0340. That is the strongest evidence the no-syntax claim has, and it is evidence that elision is total, not that the engine is complete. The interner — one table owning every string, handing out borrows for the whole compilation — is the shape with no spelling, and the bootstrap meets it at stage two or three, which is late.

    A diagnostic teaches faster than a corpus

    giving is a word no model has ever seen. The bet is that a message can teach it faster than a hundred million lines of Python can un-teach it. The evidence to watch is the SC0135 rate: if it does not fall over successive model generations, the premise is wrong, and the decision to reopen is replacing giving — with a word that is not lambda.

    The audience survives SC0140

    A script is mostly IO; IO is mostly fallible; an unchecked error is a compile error. So the most likely reason a scientist's first five-line Science program does not compile is that they ignored an error. That is the language working exactly as designed and it will not feel that way. The note's instruction is to meet it with the best diagnostic in the compiler rather than with a relaxation.