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.
| Language | Spelling | In the audience because |
|---|---|---|
| Fortran | FUNCTION | Numerical kernels that are still in production |
| MATLAB | function | Instrument control, signal processing, teaching |
| R | function | Statistics, bioinformatics |
| Julia | function | Named by the project as competition |
| JavaScript / TypeScript | function | Notebook front ends, plotting, tooling |
| PHP | function | In the count because the count was not curated |
| Python | def | The 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
| Item | Count |
|---|---|
Declarations rewritten in examples/ and fixtures | 384, across 40 files |
| Declarations embedded in Rust test sources, where no Science tool can reach them | 264 |
| Snapshots re-blessed | 116 |
| New diagnostic | 1 — SC0156 |
| Tests passing afterwards | 736 |
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.
| Spelling | Characters | Stage |
|---|---|---|
| function summarize(self) returns String: | 40 | The original English pass |
| function summarize(self) -> String: | 35 | Revision 2 |
| def summarize(self) -> String: | 30 | Revision 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
| Operation | Science | Also removed | Why the split falls here |
|---|---|---|---|
| equal | a is b | a == b | == is a programming convention, not a mathematical one |
| not equal | a is not b | a != b | Same |
| greater | a > b | a is above b | Four hundred years old; every reader of a paper knows it |
| at least | a >= b | a is at least b | Same |
| and / or / not | and 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.
| Answer | Verdict as written |
|---|---|
| Pick a default — say, the first parameter | Rejected. 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 it | Expensive, and not always decisive — a function called once has its answer chosen by its only caller, which is inference by accident. |
| Refuse the program | Kept, 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 | λ is | A line someone writes |
|---|---|---|
| Optics, spectroscopy | wavelength | let lambda be 532<nm> |
| Nuclear physics, kinetics | decay constant | let n be n0 * exp(-lambda * t) |
| Linear algebra | eigenvalue | let lambda, v be eig(a) |
| Statistics | Poisson / exponential rate | let lambda be events / interval |
| ML, optimisation | regularisation strength | def 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
| Science | Elsewhere | Reason |
|---|---|---|
| keep / discard | filter / 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 / windows | chunks / windows | "Chunk" does not say whether the pieces overlap. The contrast now carries information instead of folklore. |
| numbered | enumerate | "Enumerate" means four different things to four audiences. |
| minimum / maximum | min / max | The abbreviation rule with no exception carved out for brevity. The note flags it as the entry most likely to be argued about. |
| map | map | Kept. 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, collect | same | Kept on familiarity. reduce in particular is this audience's own word, from MapReduce and from every array library they use. |
| union, intersection | same | Kept. 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.
| Language | Statements at top level | Entry point | Import runs them |
|---|---|---|---|
| Python | yes | first statement | yes |
| R | yes | first statement | yes (source) |
| Julia | yes | first statement | yes (include) |
| MATLAB | yes | first statement | n/a — but two file kinds |
| Go | no | func main | n/a |
| Rust | no | fn main | n/a |
| Science | yes | implicit, entry file only | no — 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.
| Obligation | Because |
|---|---|
| Every parameter type survives a round trip through JSON Schema | A def may take a closure, a trait object, a borrowed slice; a tool may take none of them |
| It may not be generic | tools/list is a flat list of concrete tools with one schema each |
No parameter may be borrowed | There is no caller to borrow from — every argument was deserialized a moment ago |
| It must carry a doc comment | The description is not documentation; it is an input to the caller's dispatch decision |
| Its return type is constrained | Content, 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
| What | What happens today instead |
|---|---|
The migration block SC0120–SC0134 — &T, &mut T, dyn, fn, struct, enum, impl, pub, mut | The 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 word | A generic two-error parse failure. The word itself is correctly free |
SC0212 — naming a script binding inside a function body | SC0200, 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 discharge | Nothing. 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 level | SC0525, 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 schema | Accepted by the parser, absent from the emitted JSON — the trigger to reopen Decision 1 |
sciencec fmt canonicalising giving to each where the parameter occurs once | Left 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
| Where | What |
|---|---|
examples/21_compiler_shapes.science §4 | Still 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 line | Reads 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 amendment | Says the prelude's Index takes no parameter and IndexMutably does not exist. Both are declared now, with an of Idx parameter |
String.truncate | A 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 lexer | The 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.