# The VisiMark tutorial
The VisiMark tutorial
**From a plain Markdown table to numbers a machine checks on every commit.**
From a plain Markdown table to numbers a machine checks on every commit.
This tutorial takes you from nothing to a working setup. You will write a document, break it on purpose, watch the tool catch it, put the check into CI, and then read values back out of the document with a script.
This tutorial takes you from nothing to a working setup. You will write a document, break it on purpose, watch the tool catch it, put the check into CI, and then read values back out of the document with a script.
It is written for people who know Markdown and Git, and who have never seen VisiMark. The language is kept simple on purpose, so that it reads the same way for everyone.
It is written for people who know Markdown and Git, and who have never seen VisiMark. The language is kept simple on purpose, so that it reads the same way for everyone.
Every command output in this tutorial is a real transcript. Nothing is invented. Most were captured with `visimark 0.1.5` or `0.1.6`. Two features are newer than the last release, and the chapters that teach them say so: percent display on an anchor (chapter 15) and the maths spellings `|x|`, `⌊x⌋`, `⌈x⌉` and `√(x)` (chapter 12). Their transcripts come from the development build, and the features ship in the next release.
Every command output in this tutorial is a real transcript. Nothing is invented.
Most were captured with visimark 0.1.5 or 0.1.6. Two features are newer
than the last release, and the chapters that teach them say so: percent display
on an anchor (chapter 15) and the maths spellings |x|, ⌊x⌋, ⌈x⌉ and
√(x) (chapter 12). Their transcripts come from the development build, and
the features ship in the next release.
## How to read this
How to read this
There are 33 short chapters in nine parts. They are meant to be read in order: each one exists because the one before it left a problem open.
There are 33 short chapters in nine parts. They are meant to be read in order: each one exists because the one before it left a problem open.
| Part | Chapters | What you get | |---|---|---| | 1. The problem | 1–2 | Why this tool exists | | 2. First contact | 3–5 | A working document on your machine | | 3. The language | 6–10 | Columns, totals, anchors and sheets: the shape of a document | | 4. Numbers and functions | 11–19 | Functions, notation, `ref`, precision, percent, units, dates, assertions | | 5. Other people's documents | 20–22 | How to adopt it on files you did not write | | 6. Beyond one file | 23–24 | CSV rows and charts | | 7. Automation | 25–28 | CI, scripts, agents, your editor | | 8. Modelling | 29–30 | What-if runs that never edit the document | | 9. Putting it together | 31–33 | A full document, unaided |
| Part | Chapters | What you get |
|---|---|---|
| 1. The problem | 1–2 | Why this tool exists |
| 2. First contact | 3–5 | A working document on your machine |
| 3. The language | 6–10 | Columns, totals, anchors and sheets: the shape of a document |
| 4. Numbers and functions | 11–19 | Functions, notation, ref, precision, percent, units, dates, assertions |
| 5. Other people's documents | 20–22 | How to adopt it on files you did not write |
| 6. Beyond one file | 23–24 | CSV rows and charts |
| 7. Automation | 25–28 | CI, scripts, agents, your editor |
| 8. Modelling | 29–30 | What-if runs that never edit the document |
| 9. Putting it together | 31–33 | A full document, unaided |
Three finished documents come with this tutorial. You can run the tool against them right now:
Three finished documents come with this tutorial. You can run the tool against them right now:
- [`tutorial/order.md`](tutorial/order.md) — the small document built in chapters 4 to 19. - [`tutorial/runway.md`](tutorial/runway.md) — the model used in chapters 29 and 30. - [`tutorial/capstone.md`](tutorial/capstone.md) — the full quote built in chapter 31.
tutorial/order.md— the small document built in chapters 4 to 19.tutorial/runway.md— the model used in chapters 29 and 30.tutorial/capstone.md— the full quote built in chapter 31.
---
# Part 1 — The problem
Part 1 — The problem
## 1. A number that is no longer true
1. A number that is no longer true
Here is an order, written in ordinary Markdown.
Here is an order, written in ordinary Markdown.
```markdown | Item | Qty | Price | Net | |-----------|----:|------:|-------:| | Widgets | 4 | 12.50 | 50.00 | | Gadgets | 2 | 30.00 | 60.00 | | Sprockets | 6 | 8.00 | 48.00 | Order total: **158.00** PLN ```
| Item | Qty | Price | Net |
|-----------|----:|------:|-------:|
| Widgets | 4 | 12.50 | 50.00 |
| Gadgets | 2 | 30.00 | 60.00 |
| Sprockets | 6 | 8.00 | 48.00 |
Order total: **158.00** PLN
Every number agrees. Now somebody raises the Widgets quantity from 4 to 6, and changes nothing else.
Every number agrees. Now somebody raises the Widgets quantity from 4 to 6, and changes nothing else.
```markdown | Item | Qty | Price | Net | |-----------|----:|------:|-------:| | Widgets | 6 | 12.50 | 50.00 | | Gadgets | 2 | 30.00 | 60.00 | | Sprockets | 6 | 8.00 | 48.00 | Order total: **158.00** PLN ```
| Item | Qty | Price | Net |
|-----------|----:|------:|-------:|
| Widgets | 6 | 12.50 | 50.00 |
| Gadgets | 2 | 30.00 | 60.00 |
| Sprockets | 6 | 8.00 | 48.00 |
Order total: **158.00** PLN
Two numbers are now wrong. `Net` for Widgets should be `75.00`, and the total should be `183.00`.
Two numbers are now wrong. Net for Widgets should be 75.00, and the total
should be 183.00.
Look at what happens next:
Look at what happens next:
- The file still renders perfectly on GitHub. - Your Markdown preview shows a clean table. - Git shows a one-character diff: `4` became `6`. It looks harmless. - Your tests pass, because no test knows about this file. - A reviewer reads the sentence, sees a plausible number, and approves it.
- The file still renders perfectly on GitHub.
- Your Markdown preview shows a clean table.
- Git shows a one-character diff:
4became6. It looks harmless. - Your tests pass, because no test knows about this file.
- A reviewer reads the sentence, sees a plausible number, and approves it.
Nothing in the whole chain complains. The document is wrong and it *looks* right. That is the failure this tool exists to catch.
Nothing in the whole chain complains. The document is wrong and it looks right. That is the failure this tool exists to catch.
### Why this happens more often now
Why this happens more often now
Three things changed at the same time.
Three things changed at the same time.
**Numbers moved into text files.** Quotes, invoices, budgets, estimates, capacity plans, research notes and project plans are increasingly written in Markdown and kept in Git, because text reviews well and versions well.
Numbers moved into text files. Quotes, invoices, budgets, estimates, capacity plans, research notes and project plans are increasingly written in Markdown and kept in Git, because text reviews well and versions well.
**Markdown has no idea what a number means.** A spreadsheet knows that `D4` contains `=B4*C4`. Markdown only knows that a cell contains the characters `50.00`. When an input changes, nothing downstream is recomputed, because nothing downstream is connected.
Markdown has no idea what a number means. A spreadsheet knows that D4
contains =B4*C4. Markdown only knows that a cell contains the characters
50.00. When an input changes, nothing downstream is recomputed, because
nothing downstream is connected.
**AI agents write a lot of these documents.** An agent is reliable at writing `Net = Qty * Price`. It is unreliable at working out that `6 * 12.50` is `75.00` and then remembering to update the two totals that depend on it. The formula is language, which the agent is good at. The arithmetic is a guess.
AI agents write a lot of these documents. An agent is reliable at writing
Net = Qty * Price. It is unreliable at working out that 6 * 12.50 is 75.00
and then remembering to update the two totals that depend on it. The formula is
language, which the agent is good at. The arithmetic is a guess.
### The fix in one sentence
The fix in one sentence
Stop writing numbers that follow from other numbers. Write the formula, and let a tool write the number.
Stop writing numbers that follow from other numbers. Write the formula, and let a tool write the number.
## 2. The idea in one page
2. The idea in one page
A VisiMark document is an ordinary Markdown file. You add a fenced block of formulas below a table, and invisible HTML comments in the prose.
A VisiMark document is an ordinary Markdown file. You add a fenced block of formulas below a table, and invisible HTML comments in the prose.
````markdown | Item | Qty | Price | Net | |-----------|----:|------:|-------:| | Widgets | 4 | 12.50 | 50.00 | | Gadgets | 2 | 30.00 | 60.00 | | Sprockets | 6 | 8.00 | 48.00 | ```vmark #order Net = Qty * Price net_total = SUM(Net) ``` Order total: **158.00**<!--vmark=order.net_total--> PLN ````
| Item | Qty | Price | Net |
|-----------|----:|------:|-------:|
| Widgets | 4 | 12.50 | 50.00 |
| Gadgets | 2 | 30.00 | 60.00 |
| Sprockets | 6 | 8.00 | 48.00 |
```vmark #order
Net = Qty * Price
net_total = SUM(Net)
```
Order total: **158.00**<!--vmark=order.net_total--> PLN
That is four ideas, and it is the whole format.
That is four ideas, and it is the whole format.
**1. A `vmark` block holds the formulas for the table just above it.** The `#order` part is the sheet name.
1. A vmark block holds the formulas for the table just above it. The
#order part is the sheet name.
**2. A column rule is one formula for every row.** `Net = Qty * Price` applies to all three rows. There is no formula-per-cell. A column with no rule — `Item`, `Qty`, `Price` — is a human input, and the tool never writes to it.
2. A column rule is one formula for every row. Net = Qty * Price applies
to all three rows. There is no formula-per-cell. A column with no rule — Item,
Qty, Price — is a human input, and the tool never writes to it.
**3. A total is a named value, not a row.** `net_total = SUM(Net)` lives in the block. There is no "Total" row in the table, so the table stays a rectangle.
3. A total is a named value, not a row. net_total = SUM(Net) lives in the
block. There is no "Total" row in the table, so the table stays a rectangle.
**4. An anchor puts a value into a sentence.** `<!--vmark=order.net_total-->` is an HTML comment. It is invisible on GitHub, in VS Code preview and through pandoc. The sentence reads normally, and the number in front of the comment is now owned by the tool.
4. An anchor puts a value into a sentence. <!--vmark=order.net_total-->
is an HTML comment. It is invisible on GitHub, in VS Code preview and through
pandoc. The sentence reads normally, and the number in front of the comment is
now owned by the tool.
Now the same edit as before — `4` becomes `6` — produces this instead:
Now the same edit as before — 4 becomes 6 — produces this instead:
```console $ visimark check order.md order.md STALE order.Net · Widgets 50.00 ≠ 75.00 Qty * Price STALE order.net_total 158.00 ≠ 183.00 SUM(Net) STALE 1 prose anchors bound to the values above 3 problems (3 stale, 0 errors) $ echo $? 1 ```
$ visimark check order.md
order.md
STALE order.Net · Widgets 50.00 ≠ 75.00 Qty * Price
STALE order.net_total 158.00 ≠ 183.00 SUM(Net)
STALE 1 prose anchors bound to the values above
3 problems (3 stale, 0 errors)
$ echo $?
1
And one command repairs it:
And one command repairs it:
```console $ visimark fmt order.md order.md: updated 1 cell, 1 anchor ```
$ visimark fmt order.md
order.md: updated 1 cell, 1 anchor
Exit code `1` is the part that matters. It means this can run in CI, and a document that contradicts itself can stop a merge.
Exit code 1 is the part that matters. It means this can run in CI, and a
document that contradicts itself can stop a merge.
### What you will be able to do at the end
What you will be able to do at the end
- Write a document whose arithmetic is checked, not claimed. - Take a document someone else wrote and wire it up without retyping it. - Read every error the checker can produce, and know what fixes each one. - Fail a build when a document's numbers drift. - Read values out of a document from a script, with no export step. - Ask a document what-if questions without editing it.
- Write a document whose arithmetic is checked, not claimed.
- Take a document someone else wrote and wire it up without retyping it.
- Read every error the checker can produce, and know what fixes each one.
- Fail a build when a document's numbers drift.
- Read values out of a document from a script, with no export step.
- Ask a document what-if questions without editing it.
---
# Part 2 — First contact
Part 2 — First contact
## 3. Install it and run it once
3. Install it and run it once
VisiMark is one command-line program. It runs under Node or Bun.
VisiMark is one command-line program. It runs under Node or Bun.
Try it without installing anything:
Try it without installing anything:
```console $ npx visimark --version visimark 0.1.5 ```
$ npx visimark --version
visimark 0.1.5
Or install it for real:
Or install it for real:
```console $ npm i -g visimark # or: bun add -g visimark ```
$ npm i -g visimark # or: bun add -g visimark
On Windows, both `npx visimark` and `npm i -g visimark` work, but the launcher needs `sh` on your PATH. Git Bash or WSL provide it. Plain PowerShell does not.
On Windows, both npx visimark and npm i -g visimark work, but the launcher
needs sh on your PATH. Git Bash or WSL provide it. Plain PowerShell does not.
Everywhere below, `visimark` means "the command you just installed", or `npx visimark` if you did not install it.
Everywhere below, visimark means "the command you just installed", or
npx visimark if you did not install it.
### The help screen
The help screen
```console
$ visimark --help
visimark — spreadsheet mechanics for Markdown
usage:
visimark check FILE... [--json] read-only; exit 1 if any finding.
A table with no `vmark` rules is a
finding: run `visimark infer`, or mark
the document `<!--vmark:no-formulas-->`
if it has nothing to derive.
visimark fmt FILE... [--fix-dates] [--json]
visimark infer FILE... [--write] [--json]
visimark eval FILE [--get NAME] [--json]
visimark explain FILE [#sheet] [--json]
visimark ref [NAME] [--json] the language's builtin functions;
reads no file
visimark --version | -v | version print the version and exit
exit codes: 0 clean, 1 findings, 2 usage or read failure
```$ visimark --help
visimark — spreadsheet mechanics for Markdown
usage:
visimark check FILE... [--json] read-only; exit 1 if any finding.
A table with no `vmark` rules is a
finding: run `visimark infer`, or mark
the document `<!--vmark:no-formulas-->`
if it has nothing to derive.
visimark fmt FILE... [--fix-dates] [--json]
visimark infer FILE... [--write] [--json]
visimark eval FILE [--get NAME] [--json]
visimark explain FILE [#sheet] [--json]
visimark ref [NAME] [--json] the language's builtin functions;
reads no file
visimark --version | -v | version print the version and exit
exit codes: 0 clean, 1 findings, 2 usage or read failure
Six commands. Only one of them matters most of the time.
Six commands. Only one of them matters most of the time.
| Command | What it does | Does it write to your file? | |---|---|---| | `check` | Recomputes everything and reports what disagrees | **Never.** It is read-only. | | `fmt` | Repairs the numbers it owns, in place | Yes — computed cells and anchors only | | `infer` | Works out the rules a document's existing numbers imply | Only with `--write`, and only by inserting | | `eval` | Prints the computed values, for a script to read, and runs what-if scenarios | No | | `explain` | Prints each sheet's inputs, rules and evaluation order | No | | `ref` | Prints what a builtin function does | No — it reads no file at all |
| Command | What it does | Does it write to your file? |
|---|---|---|
check |
Recomputes everything and reports what disagrees | Never. It is read-only. |
fmt |
Repairs the numbers it owns, in place | Yes — computed cells and anchors only |
infer |
Works out the rules a document's existing numbers imply | Only with --write, and only by inserting |
eval |
Prints the computed values, for a script to read, and runs what-if scenarios | No |
explain |
Prints each sheet's inputs, rules and evaluation order | No |
ref |
Prints what a builtin function does | No — it reads no file at all |
The help screen is a summary. It does not list `eval --scenario`, the what-if option that Part 8 teaches. Every option of every command is in [`cli-reference.md`](cli-reference.md).
The help screen is a summary. It does not list eval --scenario, the what-if
option that Part 8 teaches. Every option of every command is in
cli-reference.md.
### The three exit codes
The three exit codes
This is the whole contract with CI, so learn it now.
This is the whole contract with CI, so learn it now.
| Code | Meaning | |---|---| | `0` | Nothing to fix. Advice may still have been printed. | | `1` | The document has problems. Your build should fail. | | `2` | The command could not run — missing file, no file given, a name that does not exist. This means "your request did not make sense", not "your document is wrong". |
| Code | Meaning |
|---|---|
0 |
Nothing to fix. Advice may still have been printed. |
1 |
The document has problems. Your build should fail. |
2 |
The command could not run — missing file, no file given, a name that does not exist. This means "your request did not make sense", not "your document is wrong". |
With several files, the worst code wins.
With several files, the worst code wins.
## 4. Write your first document
4. Write your first document
Create a file called `order.md`. Do it in four steps, in this order. The order matters, and chapter 5 explains why.
Create a file called order.md. Do it in four steps, in this order. The order
matters, and chapter 5 explains why.
### Step 1 — the table, with placeholders
Step 1 — the table, with placeholders
Write the inputs by hand. For any column the tool will compute, write `0.00` as a placeholder. Do not work out the real value.
Write the inputs by hand. For any column the tool will compute, write 0.00 as
a placeholder. Do not work out the real value.
```markdown | Item | Qty | Price | Net | |-----------|----:|------:|-------:| | Widgets | 4 | 12.50 | 0.00 | | Gadgets | 2 | 30.00 | 0.00 | | Sprockets | 6 | 8.00 | 0.00 | ```
| Item | Qty | Price | Net |
|-----------|----:|------:|-------:|
| Widgets | 4 | 12.50 | 0.00 |
| Gadgets | 2 | 30.00 | 0.00 |
| Sprockets | 6 | 8.00 | 0.00 |
### Step 2 — the block, immediately after the table
Step 2 — the block, immediately after the table
````markdown ```vmark #order Net = Qty * Price net_total = SUM(Net) line_count = COUNT(Item) ``` ````
```vmark #order
Net = Qty * Price
net_total = SUM(Net)
line_count = COUNT(Item)
```
The block must come **immediately after** its table. A paragraph in between detaches it, and you get a `SHEET` error.
The block must come immediately after its table. A paragraph in between
detaches it, and you get a SHEET error.
### Step 3 — the prose, with anchors
Step 3 — the prose, with anchors
```markdown The order has **0**<!--vmark=order.line_count--> lines. Net of tax it comes to **0.00**<!--vmark=order.net_total--> PLN. ```
The order has **0**<!--vmark=order.line_count--> lines. Net of tax it comes to
**0.00**<!--vmark=order.net_total--> PLN.
The placeholder text does not matter. It is an output. The tool will overwrite it.
The placeholder text does not matter. It is an output. The tool will overwrite it.
### Step 4 — let the tool fill it in
Step 4 — let the tool fill it in
```console $ visimark fmt order.md order.md: updated 3 cells, 2 anchors ```
$ visimark fmt order.md
order.md: updated 3 cells, 2 anchors
Open the file. The placeholders are gone:
Open the file. The placeholders are gone:
````markdown | Item | Qty | Price | Net | |-----------|----:|------:|-------:| | Widgets | 4 | 12.50 | 50.00 | | Gadgets | 2 | 30.00 | 60.00 | | Sprockets | 6 | 8.00 | 48.00 | ```vmark #order Net = Qty * Price net_total = SUM(Net) line_count = COUNT(Item) ``` The order has **3**<!--vmark=order.line_count--> lines. Net of tax it comes to **158.00**<!--vmark=order.net_total--> PLN. ````
| Item | Qty | Price | Net |
|-----------|----:|------:|-------:|
| Widgets | 4 | 12.50 | 50.00 |
| Gadgets | 2 | 30.00 | 60.00 |
| Sprockets | 6 | 8.00 | 48.00 |
```vmark #order
Net = Qty * Price
net_total = SUM(Net)
line_count = COUNT(Item)
```
The order has **3**<!--vmark=order.line_count--> lines. Net of tax it comes to
**158.00**<!--vmark=order.net_total--> PLN.
And it checks clean:
And it checks clean:
```console $ visimark check order.md order.md 0 problems (0 stale, 0 errors) $ echo $? 0 ```
$ visimark check order.md
order.md
0 problems (0 stale, 0 errors)
$ echo $?
0
You have never typed `50.00`, `48.00` or `158.00`. You typed the four inputs and two formulas. Everything else was derived.
You have never typed 50.00, 48.00 or 158.00. You typed the four inputs and
two formulas. Everything else was derived.
### Read the output of `check`
Read the output of check
The report has a fixed shape:
The report has a fixed shape:
``` STALE order.Net · Widgets 50.00 ≠ 75.00 Qty * Price ───── ────────────── ─────── ─────────────── ─────────── code what it is which row stored ≠ correct the rule ```
STALE order.Net · Widgets 50.00 ≠ 75.00 Qty * Price
───── ────────────── ─────── ─────────────── ───────────
code what it is which row stored ≠ correct the rule
- **code** — the kind of problem. Chapter 21 covers all of them. - **what it is** — `sheet.name`. - **which row** — the first cell of that row, so you can find it. - **stored ≠ correct** — what the file says, then what the formula says. - **the rule** — the formula that owns the number.
- code — the kind of problem. Chapter 21 covers all of them.
- what it is —
sheet.name. - which row — the first cell of that row, so you can find it.
- stored ≠ correct — what the file says, then what the formula says.
- the rule — the formula that owns the number.
## 5. Prove that it is really derived
5. Prove that it is really derived
This is the most important chapter in the tutorial. Read it twice.
This is the most important chapter in the tutorial. Read it twice.
`check` compares numbers against formulas. A document with **no formulas** has nothing to disagree with. A naive checker would call such a document clean — which is the most misleading answer it could give.
check compares numbers against formulas. A document with no formulas has
nothing to disagree with. A naive checker would call such a document clean —
which is the most misleading answer it could give.
So `check` refuses:
So check refuses:
```console
$ visimark check plain.md
plain.md
COVERAGE a table with no `vmark` rules — nothing in this document is checked
run `visimark infer` to derive them, or mark it `<!--vmark:no-formulas-->`
1 problem (0 stale, 1 error)
$ echo $?
1
```$ visimark check plain.md
plain.md
COVERAGE a table with no `vmark` rules — nothing in this document is checked
run `visimark infer` to derive them, or mark it `<!--vmark:no-formulas-->`
1 problem (0 stale, 1 error)
$ echo $?
1
`COVERAGE` exists to stop one specific lie: computing the totals in your head, typing them as plain text, running `check`, seeing `0 problems`, and reporting "the checker passes". That is a green build on a document with no build in it.
COVERAGE exists to stop one specific lie: computing the totals in your head,
typing them as plain text, running check, seeing 0 problems, and reporting
"the checker passes". That is a green build on a document with no build in it.
Two things keep `COVERAGE` from being annoying:
Two things keep COVERAGE from being annoying:
- It needs a **table** to fire. A README or a changelog is never asked for arithmetic it does not have. - It is counted for the **whole document**. A reference table that really is all input passes, as long as some other table in the file carries a rule.
- It needs a table to fire. A README or a changelog is never asked for arithmetic it does not have.
- It is counted for the whole document. A reference table that really is all input passes, as long as some other table in the file carries a rule.
### When a document really has no arithmetic
When a document really has no arithmetic
Say so in the document itself:
Say so in the document itself:
```markdown <!--vmark:no-formulas--> ```
<!--vmark:no-formulas-->
It must be on its own line, not indented, and not inside a fenced block — so a marker shown inside an example (like the one above) is documentation, not a claim.
It must be on its own line, not indented, and not inside a fenced block — so a marker shown inside an example (like the one above) is documentation, not a claim.
The marker lives in the file rather than in a CI flag. That is deliberate: the decision is about a document, not about a build. It travels with the content, it shows up in review, and `grep` finds it.
The marker lives in the file rather than in a CI flag. That is deliberate: the
decision is about a document, not about a build. It travels with the content, it
shows up in review, and grep finds it.
The marker is checked like everything else. Add rules to a marked document later and `check` reports the marker as wrong.
The marker is checked like everything else. Add rules to a marked document later
and check reports the marker as wrong.
**Never add this marker to silence a failure you have not read.** That is the one move the `COVERAGE` finding exists to prevent.
Never add this marker to silence a failure you have not read. That is the
one move the COVERAGE finding exists to prevent.
### The habit that outranks all of this
The habit that outranks all of this
A green check proves agreement. It does not prove derivation. To prove derivation, break the document on purpose:
A green check proves agreement. It does not prove derivation. To prove derivation, break the document on purpose:
```console $ sed -i 's/| 4 | 12.50 |/| 6 | 12.50 |/' order.md $ visimark check order.md order.md STALE order.Net · Widgets 50.00 ≠ 75.00 Qty * Price STALE order.net_total 158.00 ≠ 183.00 SUM(Net) STALE 1 prose anchors bound to the values above 3 problems (3 stale, 0 errors) $ git checkout order.md ```
$ sed -i 's/| 4 | 12.50 |/| 6 | 12.50 |/' order.md
$ visimark check order.md
order.md
STALE order.Net · Widgets 50.00 ≠ 75.00 Qty * Price
STALE order.net_total 158.00 ≠ 183.00 SUM(Net)
STALE 1 prose anchors bound to the values above
3 problems (3 stale, 0 errors)
$ git checkout order.md
If `check` still says `0 problems` after you changed an input, then nothing in that document is wired up. Fix that before you trust it.
If check still says 0 problems after you changed an input, then nothing in
that document is wired up. Fix that before you trust it.
Do this once for every document you set up. It takes ten seconds and it is the only test of the thing you actually care about.
Do this once for every document you set up. It takes ten seconds and it is the only test of the thing you actually care about.
---
# Part 3 — The language
Part 3 — The language
The language is small. There are two kinds of binding, sixteen functions and a handful of operators, and it is meant to stay that way. This part covers the shape of a document: columns, totals, anchors and sheets. Part 4 covers what goes inside a formula.
The language is small. There are two kinds of binding, sixteen functions and a handful of operators, and it is meant to stay that way. This part covers the shape of a document: columns, totals, anchors and sheets. Part 4 covers what goes inside a formula.
## 6. Columns: one rule for every row
6. Columns: one rule for every row
A binding whose name matches a **column header** of the table above is a **column rule**. It runs once per row.
A binding whose name matches a column header of the table above is a column rule. It runs once per row.
````markdown | Item | Qty | Price | Net | |-----------|----:|------:|-------:| | Widgets | 4 | 12.50 | 50.00 | | Gadgets | 2 | 30.00 | 60.00 | | Sprockets | 6 | 8.00 | 48.00 | ```vmark #order Net = Qty * Price ``` ````
| Item | Qty | Price | Net |
|-----------|----:|------:|-------:|
| Widgets | 4 | 12.50 | 50.00 |
| Gadgets | 2 | 30.00 | 60.00 |
| Sprockets | 6 | 8.00 | 48.00 |
```vmark #order
Net = Qty * Price
```
`Net = Qty * Price` is not three formulas. It is one rule, and every row obeys it. Inside the rule, a bare column name means "this row's value".
Net = Qty * Price is not three formulas. It is one rule, and every row obeys
it. Inside the rule, a bare column name means "this row's value".
There is no way to write a different formula for one row. This is on purpose. A per-cell exception is exactly the thing that hides in a spreadsheet and survives every review. If one row really is different, it needs a different input column or an `IF`, both of which are visible on the page.
There is no way to write a different formula for one row. This is on purpose. A
per-cell exception is exactly the thing that hides in a spreadsheet and survives
every review. If one row really is different, it needs a different input column
or an IF, both of which are visible on the page.
### Inputs are the columns with no rule
Inputs are the columns with no rule
`Item`, `Qty` and `Price` have no rule. They are **inputs**: values a person wrote down. The tool never writes to an input column. Ever.
Item, Qty and Price have no rule. They are inputs: values a person
wrote down. The tool never writes to an input column. Ever.
So every column in a table is one of two things, and you can tell which by looking at the block:
So every column in a table is one of two things, and you can tell which by looking at the block:
| Kind | Has a rule? | Who owns it | |---|---|---| | Input | No | You | | Computed | Yes | The tool |
| Kind | Has a rule? | Who owns it |
|---|---|---|
| Input | No | You |
| Computed | Yes | The tool |
### The order of rules does not matter
The order of rules does not matter
Inside a block, order is irrelevant. VisiMark works out the dependencies and evaluates in the right order. You can write the total above the rule that feeds it.
Inside a block, order is irrelevant. VisiMark works out the dependencies and evaluates in the right order. You can write the total above the rule that feeds it.
### Comments
Comments
A `#` starts a comment inside a block:
A # starts a comment inside a block:
````markdown ```vmark #plan # how long each task ran Days = End - Start ``` ````
```vmark #plan
# how long each task ran
Days = End - Start
```
The sheet id lives on the fence line, so `#` is free inside the body.
The sheet id lives on the fence line, so # is free inside the body.
## 7. Who owns which bytes
7. Who owns which bytes
VisiMark owns exactly three things in your file:
VisiMark owns exactly three things in your file:
1. **Computed cells** — cells in a column that has a rule. 2. **Anchored values** — the text in front of a `<!--vmark=…-->` comment. 3. **Generated artifacts** — chart files, covered in chapter 24.
- Computed cells — cells in a column that has a rule.
- Anchored values — the text in front of a
<!--vmark=…-->comment. - Generated artifacts — chart files, covered in chapter 24.
Everything else is yours: prose, headings, input columns, table layout, the formulas themselves.
Everything else is yours: prose, headings, input columns, table layout, the formulas themselves.
### Never hand-edit an output
Never hand-edit an output
If a computed number looks wrong, do not fix the number. Change the input or change the rule, then run `fmt`.
If a computed number looks wrong, do not fix the number. Change the input or
change the rule, then run fmt.
Hand-editing an output is the precise failure this tool exists to catch, and `fmt` will overwrite you at the next run anyway.
Hand-editing an output is the precise failure this tool exists to catch, and
fmt will overwrite you at the next run anyway.
### Why `fmt` produces small diffs
Why fmt produces small diffs
`fmt` does not re-render your Markdown. It finds each number it owns by byte position and splices just those characters. Nothing else in the file moves — no reflowed paragraphs, no renormalised `*` and `_`, no realigned tables.
fmt does not re-render your Markdown. It finds each number it owns by byte
position and splices just those characters. Nothing else in the file moves — no
reflowed paragraphs, no renormalised * and _, no realigned tables.
Here is a real diff. One input changed, from `4` to `6`, in [`tutorial/order.md`](tutorial/order.md):
Here is a real diff. One input changed, from 4 to 6, in
tutorial/order.md:
```diff -| Widgets | 4 | 12.50 | 50.00 | +| Widgets | 6 | 12.50 | 75.00 | The order has **3**<!--vmark=order.line_count--> lines. Net of tax it comes to -**158.00**<!--vmark=order.net_total--> PLN, an average of -**52.67**<!--vmark=order.avg_line--> PLN per line. +**183.00**<!--vmark=order.net_total--> PLN, an average of +**61.00**<!--vmark=order.avg_line--> PLN per line. -| Standard | 23% | 158.00 | 36.34 | +| Standard | 23% | 183.00 | 42.09 | -Tax adds **36.34**<!--vmark=tax.tax_total--> PLN, so the amount due is -**194.34**<!--vmark=tax.gross_total--> PLN. +Tax adds **42.09**<!--vmark=tax.tax_total--> PLN, so the amount due is +**225.09**<!--vmark=tax.gross_total--> PLN. ```
-| Widgets | 4 | 12.50 | 50.00 |
+| Widgets | 6 | 12.50 | 75.00 |
The order has **3**<!--vmark=order.line_count--> lines. Net of tax it comes to
-**158.00**<!--vmark=order.net_total--> PLN, an average of
-**52.67**<!--vmark=order.avg_line--> PLN per line.
+**183.00**<!--vmark=order.net_total--> PLN, an average of
+**61.00**<!--vmark=order.avg_line--> PLN per line.
-| Standard | 23% | 158.00 | 36.34 |
+| Standard | 23% | 183.00 | 42.09 |
-Tax adds **36.34**<!--vmark=tax.tax_total--> PLN, so the amount due is
-**194.34**<!--vmark=tax.gross_total--> PLN.
+Tax adds **42.09**<!--vmark=tax.tax_total--> PLN, so the amount due is
+**225.09**<!--vmark=tax.gross_total--> PLN.
Six changed lines. Every one of them is a figure that genuinely depends on that input. **The diff is the propagation.** A reviewer can see the tax, the base and the amount due all move together, and can ask whether they moved for the right reason.
Six changed lines. Every one of them is a figure that genuinely depends on that input. The diff is the propagation. A reviewer can see the tax, the base and the amount due all move together, and can ask whether they moved for the right reason.
One cosmetic note: if a new value is wider than the old one, your table columns may stop lining up. `fmt` will not re-pad the table, because the padding is yours. Give computed cells a little room when you write the placeholders, or re-align by hand when you like.
One cosmetic note: if a new value is wider than the old one, your table columns
may stop lining up. fmt will not re-pad the table, because the padding is
yours. Give computed cells a little room when you write the placeholders, or
re-align by hand when you like.
## 8. Scalars and aggregates
8. Scalars and aggregates
A binding whose name is **not** a column header is a **scalar** — a single named value.
A binding whose name is not a column header is a scalar — a single named value.
````markdown ```vmark #order Net = Qty * Price net_total = SUM(Net) line_count = COUNT(Item) ``` ````
```vmark #order
Net = Qty * Price
net_total = SUM(Net)
line_count = COUNT(Item)
```
`net_total` and `line_count` are scalars. `Net` is a column rule, because the table has a `Net` header.
net_total and line_count are scalars. Net is a column rule, because the
table has a Net header.
### The five aggregates
The five aggregates
An aggregate — the specification calls it a **reduce** — takes one whole column and returns one value. It takes a bare column name, never an expression.
An aggregate — the specification calls it a reduce — takes one whole column and returns one value. It takes a bare column name, never an expression.
| Function | Returns | |---|---| | `SUM(col)` | Total of the column. `0` over an empty column. | | `COUNT(col)` | Number of rows. | | `MIN(col)` | Least value. Works on numbers or on dates. | | `MAX(col)` | Greatest value. Works on numbers or on dates. | | `AVG(col)` | Arithmetic mean. |
| Function | Returns |
|---|---|
SUM(col) |
Total of the column. 0 over an empty column. |
COUNT(col) |
Number of rows. |
MIN(col) |
Least value. Works on numbers or on dates. |
MAX(col) |
Greatest value. Works on numbers or on dates. |
AVG(col) |
Arithmetic mean. |
### There is no totals row
There is no totals row
This is worth stating plainly, because everybody tries it once.
This is worth stating plainly, because everybody tries it once.
```markdown | Item | Qty | Price | Net | |-----------|----:|------:|-------:| | Widgets | 4 | 12.50 | 50.00 | | **Total** | | | 158.00 | ← do not do this ```
| Item | Qty | Price | Net |
|-----------|----:|------:|-------:|
| Widgets | 4 | 12.50 | 50.00 |
| **Total** | | | 158.00 | ← do not do this
A totals row breaks the rectangle. `SUM(Net)` would then include the total itself, and `COUNT(Item)` would count a row that is not a line item. A total is a scalar, and it reaches the reader through an anchor.
A totals row breaks the rectangle. SUM(Net) would then include the total
itself, and COUNT(Item) would count a row that is not a line item. A total is
a scalar, and it reaches the reader through an anchor.
### The trap: a rule whose name is not a column
The trap: a rule whose name is not a column
This is the mistake everybody makes once. Suppose the header is `Price`, and you write:
This is the mistake everybody makes once. Suppose the header is Price, and you
write:
````markdown ```vmark #order Net = Qty * Prise ``` ````
```vmark #order
Net = Qty * Prise
```
That is caught, because `Prise` does not exist:
That is caught, because Prise does not exist:
```console
UNDEF s.Net unknown name `Prise`
did you mean `Price`?
``` UNDEF s.Net unknown name `Prise`
did you mean `Price`?
But now suppose you misspell the **left** side:
But now suppose you misspell the left side:
````markdown ```vmark #order Nett = Qty * Price ``` ````
```vmark #order
Nett = Qty * Price
```
`Nett` is not a column header, so it is not a column rule. It quietly becomes a scalar. Your `Net` column is still an input full of hand-typed numbers, and the checker has nothing to say about it.
Nett is not a column header, so it is not a column rule. It quietly becomes a
scalar. Your Net column is still an input full of hand-typed numbers, and the
checker has nothing to say about it.
This is why the tool warns about a value nobody reads:
This is why the tool warns about a value nobody reads:
```console WARN order.Nett defined and never read — did you mean `Net`? ```
WARN order.Nett defined and never read — did you mean `Net`?
`WARN` is advice. It does not fail the build. But a scalar nobody reads is almost always a typo on the left-hand side of a rule, so treat it as a real signal.
WARN is advice. It does not fail the build. But a scalar nobody reads is
almost always a typo on the left-hand side of a rule, so treat it as a real
signal.
This is also the second reason for chapter 5's habit: change an input, and the column that never updates is the column that was never wired up.
This is also the second reason for chapter 5's habit: change an input, and the column that never updates is the column that was never wired up.
### A scalar someone may want to vary
A scalar someone may want to vary
A scalar such as `vat_rate = 23%` is fixed in the text. If you want to ask *what would this come to at 8%?*, do not edit the number and put it back later. Declare it a `param`, and ask the question with `eval --scenario`. The document never changes. Part 8 covers this in full.
A scalar such as vat_rate = 23% is fixed in the text. If you want to ask
what would this come to at 8%?, do not edit the number and put it back
later. Declare it a param, and ask the question with eval --scenario. The
document never changes. Part 8 covers this in full.
## 9. Anchors: a number inside a sentence
9. Anchors: a number inside a sentence
A total is useless if a reader has to run a tool to see it. An **anchor** puts the value into the prose.
A total is useless if a reader has to run a tool to see it. An anchor puts the value into the prose.
```markdown Net of tax it comes to **158.00**<!--vmark=order.net_total--> PLN. ```
Net of tax it comes to **158.00**<!--vmark=order.net_total--> PLN.
The comment sits immediately after the value it owns. It rewrites the text of the inline element directly before it: **bold**, *emphasis*, `code`, or plain text.
The comment sits immediately after the value it owns. It rewrites the text of
the inline element directly before it: bold, emphasis, code, or plain
text.
The comment is invisible in GitHub, in VS Code preview, and through pandoc to HTML and Word. The sentence reads normally. Nobody but you and the tool knows it is there.
The comment is invisible in GitHub, in VS Code preview, and through pandoc to HTML and Word. The sentence reads normally. Nobody but you and the tool knows it is there.
### An anchor names `sheet.name`, always
An anchor names sheet.name, always
```markdown **158.00**<!--vmark=order.net_total--> ```
**158.00**<!--vmark=order.net_total-->
The sheet id is required. A value defined outside any sheet cannot be anchored, so put anything you want to say in prose inside a named sheet.
The sheet id is required. A value defined outside any sheet cannot be anchored, so put anything you want to say in prose inside a named sheet.
### An anchor is an output, never an input
An anchor is an output, never an input
The text in front of the comment states a value. It never decides one.
The text in front of the comment states a value. It never decides one.
- It does not decide how many decimals to print. That comes from the binding (chapter 14). - It is never read back by the evaluator. - Two anchors on the same value cannot disagree, because neither is consulted.
- It does not decide how many decimals to print. That comes from the binding (chapter 14).
- It is never read back by the evaluator.
- Two anchors on the same value cannot disagree, because neither is consulted.
So writing `**0**` as a placeholder does not mean "zero decimals". Whatever you type is replaced at the binding's own width.
So writing **0** as a placeholder does not mean "zero decimals". Whatever you
type is replaced at the binding's own width.
### Always give an anchor a placeholder
Always give an anchor a placeholder
The anchor rewrites the element directly in front of it. With `**0**` in front, that element is the bold text, which is what you want. With plain prose in front, it is the last word of that prose:
The anchor rewrites the element directly in front of it. With **0** in front,
that element is the bold text, which is what you want. With plain prose in
front, it is the last word of that prose:
```console $ tail -1 seed.md Net of tax it comes to <!--vmark=order.net_total--> PLN. $ visimark fmt seed.md seed.md: updated 1 anchor $ tail -1 seed.md Net of tax it comes 158.00 <!--vmark=order.net_total--> PLN. ```
$ tail -1 seed.md
Net of tax it comes to <!--vmark=order.net_total--> PLN.
$ visimark fmt seed.md
seed.md: updated 1 anchor
$ tail -1 seed.md
Net of tax it comes 158.00 <!--vmark=order.net_total--> PLN.
The word `to` was replaced by the number. So write a placeholder, and make it bold: `**0**<!--vmark=order.net_total-->`. The placeholder's digits do not matter. What `fmt` never does is *invent* an anchor — you decide where a value appears in your prose.
The word to was replaced by the number. So write a placeholder, and make it
bold: **0**<!--vmark=order.net_total-->. The placeholder's digits do not
matter. What fmt never does is invent an anchor — you decide where a value
appears in your prose.
### Print a ratio as a percent
Print a ratio as a percent
A stored ratio such as `0.3988` reads better in a sentence as `39.88%`. Add `%` to the end of the anchor name, `<!--vmark=lines.margin%-->`, and `fmt` writes the percent. The stored value does not change. Chapter 15 covers it.
A stored ratio such as 0.3988 reads better in a sentence as 39.88%. Add %
to the end of the anchor name, <!--vmark=lines.margin%-->, and fmt writes
the percent. The stored value does not change. Chapter 15 covers it.
### Put the currency outside the anchor
Put the currency outside the anchor
```markdown **158.00**<!--vmark=order.net_total--> PLN ← right **158.00 PLN**<!--vmark=order.net_total--> ← wrong ```
**158.00**<!--vmark=order.net_total--> PLN ← right
**158.00 PLN**<!--vmark=order.net_total--> ← wrong
In the second form, `PLN` is inside the value the tool owns, and the tool will rewrite the whole thing.
In the second form, PLN is inside the value the tool owns, and the tool will
rewrite the whole thing.
### One current limit
One current limit
Today only **numeric** anchored values are rewritten and checked. If a value is a string or a date, the anchor is left alone and no `STALE` is reported for it. Until that changes, keep strings and dates out of prose anchors, or accept that they are documentation rather than verified figures. Numbers — which is almost everything you want to anchor — are fully checked.
Today only numeric anchored values are rewritten and checked. If a value is
a string or a date, the anchor is left alone and no STALE is reported for it.
Until that changes, keep strings and dates out of prose anchors, or accept that
they are documentation rather than verified figures. Numbers — which is almost
everything you want to anchor — are fully checked.
## 10. More than one table: sheets
10. More than one table: sheets
A real document has several tables. Each `vmark` block names a **sheet**, and a sheet owns the table immediately above it.
A real document has several tables. Each vmark block names a sheet, and a
sheet owns the table immediately above it.
Here is the second half of [`tutorial/order.md`](tutorial/order.md):
Here is the second half of tutorial/order.md:
````markdown | Band | Rate | Base | Tax | |----------|-----:|-------:|------:| | Standard | 23% | 158.00 | 36.34 | ```vmark #tax Base = order.net_total Tax = ROUND(Base * Rate, 2) tax_total = SUM(Tax) gross_total = order.net_total + tax_total ``` ````
| Band | Rate | Base | Tax |
|----------|-----:|-------:|------:|
| Standard | 23% | 158.00 | 36.34 |
```vmark #tax
Base = order.net_total
Tax = ROUND(Base * Rate, 2)
tax_total = SUM(Tax)
gross_total = order.net_total + tax_total
```
`order.net_total` reaches across from the first sheet. That is the whole mechanism: a qualified name, `sheet.value`.
order.net_total reaches across from the first sheet. That is the whole
mechanism: a qualified name, sheet.value.
### Two rules that bite
Two rules that bite
**A cross-sheet reference must be qualified.** Inside `#tax`, a bare `net_total` means nothing. Write `order.net_total`.
A cross-sheet reference must be qualified. Inside #tax, a bare net_total
means nothing. Write order.net_total.
**A column from another sheet must be aggregated.** A column is not a value:
A column from another sheet must be aggregated. A column is not a value:
````markdown ```vmark #schedule Amount = Share * order.Net ``` ````
```vmark #schedule
Amount = Share * order.Net
```
```console
VECTOR schedule.Amount `order.Net` is a column, not a value.
Wrap it in an aggregate: SUM(order.Net)
``` VECTOR schedule.Amount `order.Net` is a column, not a value.
Wrap it in an aggregate: SUM(order.Net)
The error message tells you the fix. Inside its own sheet, a bare column name is fine, because a column rule runs per row and "this row's value" is meaningful. Across a sheet boundary there is no "this row", so you must collapse the column to one number.
The error message tells you the fix. Inside its own sheet, a bare column name is fine, because a column rule runs per row and "this row's value" is meaningful. Across a sheet boundary there is no "this row", so you must collapse the column to one number.
### A block with no table
A block with no table
If a block declares only scalars, it does not need a table above it. This is how you write a reconciliation section:
If a block declares only scalars, it does not need a table above it. This is how you write a reconciliation section:
````markdown ```vmark #recon invoiced = lines.gross_total scheduled = schedule.covered variance = scheduled - invoiced ``` ````
```vmark #recon
invoiced = lines.gross_total
scheduled = schedule.covered
variance = scheduled - invoiced
```
A block that declares **column rules** and has no table above it is a `SHEET` error.
A block that declares column rules and has no table above it is a SHEET
error.
### Blank lines do not detach a block
Blank lines do not detach a block
A block owns the table immediately above it, ignoring blank lines. A *paragraph* in between does detach it. If you see a `SHEET` error, look for prose that wandered between a table and its block.
A block owns the table immediately above it, ignoring blank lines. A paragraph
in between does detach it. If you see a SHEET error, look for prose that
wandered between a table and its block.
---
# Part 4 — Numbers and functions
Part 4 — Numbers and functions
Part 3 gave a document its shape. This part is about what goes inside a formula: the functions, the maths spellings some of them have, the command that tells you what each one does, and the rules for how wide a number is written. It ends with the kinds of values a cell can hold — percentages, currencies, dates — and with assertions.
Part 3 gave a document its shape. This part is about what goes inside a formula: the functions, the maths spellings some of them have, the command that tells you what each one does, and the rules for how wide a number is written. It ends with the kinds of values a cell can hold — percentages, currencies, dates — and with assertions.
## 11. Functions that run per row
11. Functions that run per row
An aggregate collapses a column. A **map** does the opposite job: it runs once per row, inside a column rule, and turns one value into one value.
An aggregate collapses a column. A map does the opposite job: it runs once per row, inside a column rule, and turns one value into one value.
| Function | What it does | |---|---| | `ROUND(x, places)` | Round to `places` decimals. Ties go away from zero. | | `ABS(x)` | Drop the sign. Also written `\|x\|` (chapter 12). | | `MOD(x, y)` | Remainder. A zero divisor is a `TYPE` error. | | `SQRT(x)` | Square root. A negative input is a `TYPE` error. Also written `√(x)`. | | `FLOOR(x, s)` | Largest multiple of `s` not above `x`. `⌊x⌋` is `FLOOR(x, 1)`. | | `CEILING(x, s)` | Smallest multiple of `s` not below `x`. `⌈x⌉` is `CEILING(x, 1)`. | | `IF(cond, a, b)` | `a` when `cond` is true, otherwise `b`. | | `EOMONTH(d, months)` | Last day of the month `months` away from `d`. |
| Function | What it does |
|---|---|
ROUND(x, places) |
Round to places decimals. Ties go away from zero. |
ABS(x) |
Drop the sign. Also written |x| (chapter 12). |
MOD(x, y) |
Remainder. A zero divisor is a TYPE error. |
SQRT(x) |
Square root. A negative input is a TYPE error. Also written √(x). |
FLOOR(x, s) |
Largest multiple of s not above x. ⌊x⌋ is FLOOR(x, 1). |
CEILING(x, s) |
Smallest multiple of s not below x. ⌈x⌉ is CEILING(x, 1). |
IF(cond, a, b) |
a when cond is true, otherwise b. |
EOMONTH(d, months) |
Last day of the month months away from d. |
Maps and aggregates compose freely, because both produce a single value:
Maps and aggregates compose freely, because both produce a single value:
``` Share precision 4 = Net / SUM(Net) ```
Share precision 4 = Net / SUM(Net)
`SUM(Net)` collapses the column to a number, and then the division runs once per row. This is legal and useful.
SUM(Net) collapses the column to a number, and then the division runs once per
row. This is legal and useful.
### Operators
Operators
`+` `-` `*` `/` `^`, the comparisons `==` `!=` `<` `<=` `>` `>=`, and the words `and`, `or`, `not`. Division by zero is a `TYPE` error, not a value: `fmt` never writes `Infinity` into your document.
+ - * / ^, the comparisons == != < <= > >=, and the words
and, or, not. Division by zero is a TYPE error, not a value: fmt never
writes Infinity into your document.
`%` is not an operator. It is postfix only and belongs to a number, so that `23%` can never be ambiguous. Use `MOD()` for a remainder.
% is not an operator. It is postfix only and belongs to a number, so that
23% can never be ambiguous. Use MOD() for a remainder.
`|` is not an operator either. It is the absolute-value bracket, `|x|`, which chapter 12 covers.
| is not an operator either. It is the absolute-value bracket, |x|, which
chapter 12 covers.
Equality is `==`. A single `=` only ever binds a name.
Equality is ==. A single = only ever binds a name.
### Booleans exist, but never land in a cell
Booleans exist, but never land in a cell
A comparison produces a boolean. `IF()`, `and`, `or`, `not` and `assert` consume one. Nothing else can.
A comparison produces a boolean. IF(), and, or, not and assert consume
one. Nothing else can.
There are no `true` and `false` literals, and storing a boolean is an error:
There are no true and false literals, and storing a boolean is an error:
````markdown ```vmark #plan Flag = Days > 30 ``` ````
```vmark #plan
Flag = Days > 30
```
```console TYPE plan.Flag a boolean cannot be stored; wrap it in `IF()` to produce a number or a string ```
TYPE plan.Flag a boolean cannot be stored; wrap it in `IF()` to produce a number or a string
Write this instead:
Write this instead:
``` Late = IF(Days > 30, 1, 0) ```
Late = IF(Days > 30, 1, 0)
The reason is that a stored value must be a number, a date or a string — nothing else. If a cell could hold a boolean, then the English word `true` in an input column would silently change type.
The reason is that a stored value must be a number, a date or a string — nothing
else. If a cell could hold a boolean, then the English word true in an input
column would silently change type.
## 12. Maths notation: `|x|`, `⌊x⌋`, `⌈x⌉`, `√(x)`
12. Maths notation: |x|, ⌊x⌋, ⌈x⌉, √(x)
*New after 0.1.6: this ships in the next release.*
New after 0.1.6: this ships in the next release.
A formula is read by people who know maths but have never learned a function call. `ABS(variance) <= 0.05` makes them stop and think. `|variance| <= 0.05` does not. So five functions can also be written the way a textbook writes them:
A formula is read by people who know maths but have never learned a function
call. ABS(variance) <= 0.05 makes them stop and think. |variance| <= 0.05 does
not. So five functions can also be written the way a textbook writes them:
| Written | Is exactly | |---|---| | `\|x\|` | `ABS(x)` | | `⌊x⌋` | `FLOOR(x, 1)` | | `⌈x⌉` | `CEILING(x, 1)` | | `√(x)` | `SQRT(x)` | | `Σ(col)` or `∑(col)` | `SUM(col)` |
| Written | Is exactly |
|---|---|
|x| |
ABS(x) |
⌊x⌋ |
FLOOR(x, 1) |
⌈x⌉ |
CEILING(x, 1) |
√(x) |
SQRT(x) |
Σ(col) or ∑(col) |
SUM(col) |
(The `\|` above is only because this is a Markdown table. Inside a `vmark` block you write a plain `|`.)
(The \| above is only because this is a Markdown table. Inside a vmark
block you write a plain |.)
Each spelling turns into the function call before anything else happens. There is nothing new to learn about what it does: the same result, the same precision, the same errors. Both spellings are legal, and you can mix them in one block.
Each spelling turns into the function call before anything else happens. There is nothing new to learn about what it does: the same result, the same precision, the same errors. Both spellings are legal, and you can mix them in one block.
### An example
An example
A packing list, where every box holds twelve:
A packing list, where every box holds twelve:
````markdown | Item | Qty | Boxes | Loose | |-----------|----:|------:|------:| | Widgets | 40 | 4 | 4 | | Gadgets | 12 | 1 | 0 | | Sprockets | 30 | 3 | 6 | ```vmark #packing Boxes = ⌈Qty / 12⌉ Loose = Qty - ⌊Qty / 12⌋ * 12 boxes_total = Σ(Boxes) ``` The order ships in **8**<!--vmark=packing.boxes_total--> boxes of twelve. ````
| Item | Qty | Boxes | Loose |
|-----------|----:|------:|------:|
| Widgets | 40 | 4 | 4 |
| Gadgets | 12 | 1 | 0 |
| Sprockets | 30 | 3 | 6 |
```vmark #packing
Boxes = ⌈Qty / 12⌉
Loose = Qty - ⌊Qty / 12⌋ * 12
boxes_total = Σ(Boxes)
```
The order ships in **8**<!--vmark=packing.boxes_total--> boxes of twelve.
`fmt` filled in the `Boxes` and `Loose` columns and the `8`. Look at what `explain` says about the widths:
fmt filled in the Boxes and Loose columns and the 8. Look at what
explain says about the widths:
```console
$ visimark explain packing.md
#packing
inputs: Item, Qty
rules:
Boxes = ⌈Qty / 12⌉ precision 0 (derived)
Loose = Qty - ⌊Qty / 12⌋ * 12 precision 0 (derived)
scalars:
boxes_total = Σ(Boxes) precision 0 (derived)
order: Boxes → Loose → boxes_total
```$ visimark explain packing.md
#packing
inputs: Item, Qty
rules:
Boxes = ⌈Qty / 12⌉ precision 0 (derived)
Loose = Qty - ⌊Qty / 12⌋ * 12 precision 0 (derived)
scalars:
boxes_total = Σ(Boxes) precision 0 (derived)
order: Boxes → Loose → boxes_total
`Qty / 12` is a division, and a division alone has no width (chapter 14). But `⌈…⌉` rounds up to a whole number, so the result has a width of 0, and no `precision` clause is needed. `explain` prints each rule the way you wrote it.
Qty / 12 is a division, and a division alone has no width (chapter 14). But
⌈…⌉ rounds up to a whole number, so the result has a width of 0, and no
precision clause is needed. explain prints each rule the way you wrote it.
### The rules
The rules
**`√` needs parentheses.** Write `√(x)`, never `√x`. The parentheses show exactly what is under the root.
√ needs parentheses. Write √(x), never √x. The parentheses show
exactly what is under the root.
**`√(x)` still needs a precision**, exactly like `SQRT(x)`, because a square root has no natural width:
√(x) still needs a precision, exactly like SQRT(x), because a square
root has no natural width:
```console
PRECISION s.R `√(x)` has no derivable precision
declare the width: `R precision N = …`
``` PRECISION s.R `√(x)` has no derivable precision
declare the width: `R precision N = …`
**`⌊x⌋` and `⌈x⌉` always round to a whole number.** For another step, such as the nearest 0.05, use the function: `FLOOR(x, 0.05)`.
⌊x⌋ and ⌈x⌉ always round to a whole number. For another step, such as
the nearest 0.05, use the function: FLOOR(x, 0.05).
**A pair must match.** `⌊x⌉` opens a floor and closes a ceiling, and it is an error:
A pair must match. ⌊x⌉ opens a floor and closes a ceiling, and it is an
error:
```console TYPE packing.Loose expected `⌋` ```
TYPE packing.Loose expected `⌋`
**Bars nest.** `||a - b| - 1|` is `ABS(ABS(a - b) - 1)`. A `|` that starts a value opens a pair, and a `|` that follows a complete value closes one.
Bars nest. ||a - b| - 1| is ABS(ABS(a - b) - 1). A | that starts a
value opens a pair, and a | that follows a complete value closes one.
**The tools keep your spelling.** `fmt` never rewrites `|x|` to `ABS(x)` or the other way round, and `explain` prints what you wrote. `infer` proposes rules with the function names.
The tools keep your spelling. fmt never rewrites |x| to ABS(x) or the
other way round, and explain prints what you wrote. infer proposes rules
with the function names.
**`ref` knows the function names only.** `visimark ref ABS` works; `visimark ref '|'` does not. The spellings are listed under each function in [`function-reference.md`](function-reference.md).
ref knows the function names only. visimark ref ABS works;
visimark ref '|' does not. The spellings are listed under each function in
function-reference.md.
### When to use which
When to use which
Use the notation where a reader already knows the symbol: a tolerance (`|variance| <= 0.05`), a whole number of boxes (`⌈Qty / 12⌉`), a distance (`√(dx^2 + dy^2)`). Use the function name where the function takes a second argument that matters, such as `ROUND(x, 2)` or `FLOOR(x, 0.05)`.
Use the notation where a reader already knows the symbol: a tolerance
(|variance| <= 0.05), a whole number of boxes (⌈Qty / 12⌉), a distance
(√(dx^2 + dy^2)). Use the function name where the function takes a second
argument that matters, such as ROUND(x, 2) or FLOOR(x, 0.05).
The glyphs are ordinary Unicode characters: `⌊` U+230A, `⌋` U+230B, `⌈` U+2308, `⌉` U+2309, `√` U+221A, `Σ` U+03A3. Look-alikes, such as the full-width `|`, are refused rather than guessed.
The glyphs are ordinary Unicode characters: ⌊ U+230A, ⌋ U+230B, ⌈ U+2308,
⌉ U+2309, √ U+221A, Σ U+03A3. Look-alikes, such as the full-width |,
are refused rather than guessed.
## 13. Look it up: `visimark ref`
13. Look it up: visimark ref
What does `FLOOR` do to a negative number? Does `ROUND` send `2.5` up or to the nearest even number? What does `EOMONTH` do to 31 January? These details are exactly how a document ends up plausible and wrong. Do not guess them, and do not trust your memory of another tool. Ask:
What does FLOOR do to a negative number? Does ROUND send 2.5 up or to the
nearest even number? What does EOMONTH do to 31 January? These details are
exactly how a document ends up plausible and wrong. Do not guess them, and do
not trust your memory of another tool. Ask:
```console
$ visimark ref FLOOR
FLOOR(x, s) — map, 2 arguments
Greatest multiple of `s` that does not exceed `x`, toward −∞.
x number the value to round down
s number the positive step to round to
returns number
precision the width of `s`
errors
a non-positive `s` TYPE
examples
FLOOR(7, 3) = 6
FLOOR(-7, 3) = -9
see also CEILING, ROUND
```$ visimark ref FLOOR
FLOOR(x, s) — map, 2 arguments
Greatest multiple of `s` that does not exceed `x`, toward −∞.
x number the value to round down
s number the positive step to round to
returns number
precision the width of `s`
errors
a non-positive `s` TYPE
examples
FLOOR(7, 3) = 6
FLOOR(-7, 3) = -9
see also CEILING, ROUND
`ref` is the only command that reads no file. It answers about the language, not about a document, so you can run it anywhere.
ref is the only command that reads no file. It answers about the language,
not about a document, so you can run it anywhere.
### How to read an entry
How to read an entry
- **The first line** — the call shape, whether it is a *map* (one value in, one value out, per row) or a *reduce* (a whole column in, one value out), and how many arguments it takes. - **The summary** — one sentence. `toward −∞` is the answer to the negative number question: `FLOOR(-7, 3)` is `-9`, not `-6`. - **The parameters** — each name, its type and what it means. - **`returns`** — a number or a date. - **`precision`** — how wide the result is written. This is the line to read when `check` reports a `PRECISION` finding (chapter 14). For `FLOOR` it is the width of the step. For `AVG` and `SQRT` it says `must be declared`. - **`rounding`** — present when rounding is the point. `ref ROUND` says `Ties round away from zero (half-up), not to even.` - **`errors`** — every input the function refuses, and the finding code you get. - **`examples`** — worked cases. - **`see also`** — related functions.
- The first line — the call shape, whether it is a map (one value in, one value out, per row) or a reduce (a whole column in, one value out), and how many arguments it takes.
- The summary — one sentence.
toward −∞is the answer to the negative number question:FLOOR(-7, 3)is-9, not-6. - The parameters — each name, its type and what it means.
returns— a number or a date.precision— how wide the result is written. This is the line to read whencheckreports aPRECISIONfinding (chapter 14). ForFLOORit is the width of the step. ForAVGandSQRTit saysmust be declared.rounding— present when rounding is the point.ref ROUNDsaysTies round away from zero (half-up), not to even.errors— every input the function refuses, and the finding code you get.examples— worked cases.see also— related functions.
### The examples are tests
The examples are tests
Every example `ref` prints is run against the engine in CI. If the engine and the reference ever disagree, the build fails. So `ref` does not describe what the function was meant to do. It describes what it does.
Every example ref prints is run against the engine in CI. If the engine and
the reference ever disagree, the build fails. So ref does not describe what
the function was meant to do. It describes what it does.
### The whole list, and a name you misspelled
The whole list, and a name you misspelled
Bare `visimark ref` lists all sixteen:
Bare visimark ref lists all sixteen:
```console $ visimark ref SUM(col) reduce, 1 argument MIN(col) reduce, 1 argument MAX(col) reduce, 1 argument AVG(col) reduce, 1 argument COUNT(col) reduce, 1 argument ROUND(x, places) map, 2 arguments ABS(x) map, 1 argument MOD(x, y) map, 2 arguments SQRT(x) map, 1 argument FLOOR(x, s) map, 2 arguments CEILING(x, s) map, 2 arguments IF(cond, a, b) map, 3 arguments EOMONTH(d, months) map, 2 arguments ```
$ visimark ref
SUM(col) reduce, 1 argument
MIN(col) reduce, 1 argument
MAX(col) reduce, 1 argument
AVG(col) reduce, 1 argument
COUNT(col) reduce, 1 argument
ROUND(x, places) map, 2 arguments
ABS(x) map, 1 argument
MOD(x, y) map, 2 arguments
SQRT(x) map, 1 argument
FLOOR(x, s) map, 2 arguments
CEILING(x, s) map, 2 arguments
IF(cond, a, b) map, 3 arguments
EOMONTH(d, months) map, 2 arguments
A name that is not a builtin exits `2`, with a suggestion when one is close:
A name that is not a builtin exits 2, with a suggestion when one is close:
```console $ visimark ref FLORR visimark: unknown function `FLORR` — did you mean `FLOOR`? $ echo $? 2 ```
$ visimark ref FLORR
visimark: unknown function `FLORR` — did you mean `FLOOR`?
$ echo $?
2
Names are upper case. `visimark ref floor` is refused too.
Names are upper case. visimark ref floor is refused too.
### For a program: `--json`
For a program: --json
```console
$ visimark ref FLOOR --json
{
"command": "ref",
"visimark": "0.1.6",
"status": "ok",
"function": {
"name": "FLOOR",
"kind": "map",
"arity": 2,
"signature": "FLOOR(x, s)",
"summary": "greatest multiple of `s` that does not exceed `x`, toward −∞",
"params": [
{ "name": "x", "type": "number", "note": "the value to round down" },
{ "name": "s", "type": "number", "note": "the positive step to round to" }
],
"returns": "number",
"precision": {
"from": "argument-scale",
"param": "s",
"text": "the width of `s`"
},
…
```$ visimark ref FLOOR --json
{
"command": "ref",
"visimark": "0.1.6",
"status": "ok",
"function": {
"name": "FLOOR",
"kind": "map",
"arity": 2,
"signature": "FLOOR(x, s)",
"summary": "greatest multiple of `s` that does not exceed `x`, toward −∞",
"params": [
{ "name": "x", "type": "number", "note": "the value to round down" },
{ "name": "s", "type": "number", "note": "the positive step to round to" }
],
"returns": "number",
"precision": {
"from": "argument-scale",
"param": "s",
"text": "the width of `s`"
},
…
The JSON above is shortened and re-indented. An agent that writes formulas should read this before it uses a function it is not sure about (chapter 27).
The JSON above is shortened and re-indented. An agent that writes formulas should read this before it uses a function it is not sure about (chapter 27).
### The same content, in three other places
The same content, in three other places
The same reference, from the same source, is in [`function-reference.md`](function-reference.md), in the hover text of the VS Code extension (chapter 28), and in the playground. None of them is a copy someone keeps up to date by hand.
The same reference, from the same source, is in
function-reference.md, in the hover text of the VS
Code extension (chapter 28), and in the playground. None of them is a copy
someone keeps up to date by hand.
## 14. Precision: how wide a number is written
14. Precision: how wide a number is written
A number has to be written with some number of decimals. VisiMark does not have a setting for that, and it never reads it from your prose. The width belongs to the binding that produces the number, and it comes from one of three places:
A number has to be written with some number of decimals. VisiMark does not have a setting for that, and it never reads it from your prose. The width belongs to the binding that produces the number, and it comes from one of three places:
1. **Declared** — you wrote `precision N` on the binding. 2. **Derived** — the arithmetic decides it, for the operations where that is exact. 3. **Neither** — a `PRECISION` error. The tool does not guess.
- Declared — you wrote
precision Non the binding. - Derived — the arithmetic decides it, for the operations where that is exact.
- Neither — a
PRECISIONerror. The tool does not guess.
Most of the time the width is derived and you never think about it. This chapter is about the times you do.
Most of the time the width is derived and you never think about it. This chapter is about the times you do.
### Where width comes from
Where width comes from
| Construct | Width of the result | |---|---| | A number literal, such as `12.50` | The decimals as written: 2 | | A percent literal, such as `23%` or `12.5%` | The written decimals plus 2: `23%` is 2, `12.5%` is 3 | | An input column | The most decimals in any of its cells | | `+` `-` | The wider of the two operands | | `*` | The two widths added together | | `^` with a whole-number exponent | The base's width times the exponent | | `SUM` `MIN` `MAX` | The width of the column | | `COUNT` | Always 0 | | `ROUND(x, places)` | The value of `places` | | `FLOOR(x, s)` / `CEILING(x, s)` | The width of `s` — so `⌊x⌋` and `⌈x⌉` are 0 | | `ABS(x)`, `\|x\|` | The width of `x` | | `MOD(x, y)` | The wider of the two | | `IF(c, a, b)` | The wider of `a` and `b` | | `/` `AVG` `SQRT` `√` | **Nothing. You must declare it.** |
| Construct | Width of the result |
|---|---|
A number literal, such as 12.50 |
The decimals as written: 2 |
A percent literal, such as 23% or 12.5% |
The written decimals plus 2: 23% is 2, 12.5% is 3 |
| An input column | The most decimals in any of its cells |
+ - |
The wider of the two operands |
* |
The two widths added together |
^ with a whole-number exponent |
The base's width times the exponent |
SUM MIN MAX |
The width of the column |
COUNT |
Always 0 |
ROUND(x, places) |
The value of places |
FLOOR(x, s) / CEILING(x, s) |
The width of s — so ⌊x⌋ and ⌈x⌉ are 0 |
ABS(x), |x| |
The width of x |
MOD(x, y) |
The wider of the two |
IF(c, a, b) |
The wider of a and b |
/ AVG SQRT √ |
Nothing. You must declare it. |
Every rule in this table has the same reason: the exact result always fits in that width. So **a derived width never drops a digit.** Only a declared width can, and only because you asked it to.
Every rule in this table has the same reason: the exact result always fits in that width. So a derived width never drops a digit. Only a declared width can, and only because you asked it to.
You never have to learn this table by heart. `visimark ref NAME` prints the `precision` line for any function (chapter 13), and `visimark explain` prints the width of every binding and says whether it was `derived` or `declared` (chapter 22).
You never have to learn this table by heart. visimark ref NAME prints the
precision line for any function (chapter 13), and visimark explain prints
the width of every binding and says whether it was derived or declared
(chapter 22).
### The three that bound nothing
The three that bound nothing
Division, `AVG` and `SQRT` produce a result whose decimals do not follow from their inputs. `10 / 3` has no natural width. So a binding that uses one must say how wide it writes:
Division, AVG and SQRT produce a result whose decimals do not follow from
their inputs. 10 / 3 has no natural width. So a binding that uses one must say
how wide it writes:
``` avg_line precision 2 = net_total / line_count ```
avg_line precision 2 = net_total / line_count
`N` runs from 0 to 18. Not declaring it is an error:
N runs from 0 to 18. Not declaring it is an error:
```console
PRECISION s.per_item `total / COUNT(Net)` has no derivable precision
declare the width: `per_item precision N = …`
``` PRECISION s.per_item `total / COUNT(Net)` has no derivable precision
declare the width: `per_item precision N = …`
`fmt` will not guess for you. Take the width from what the document already shows, or decide it.
fmt will not guess for you. Take the width from what the document already
shows, or decide it.
There is a second way out: wrap the division in something that does have a width. `ROUND(net_total / line_count, 2)` has width 2, and `⌈Qty / 12⌉` has width 0 (chapter 12). Neither needs a `precision` clause, and the rounding is written where a reader can see it.
There is a second way out: wrap the division in something that does have a
width. ROUND(net_total / line_count, 2) has width 2, and ⌈Qty / 12⌉ has
width 0 (chapter 12). Neither needs a precision clause, and the rounding is
written where a reader can see it.
### A value nobody writes needs no width
A value nobody writes needs no width
The finding only appears when a value has to be **written** somewhere — into a cell or an anchor. A scalar with no anchor is a working value. It keeps full precision, and it needs no declaration.
The finding only appears when a value has to be written somewhere — into a cell or an anchor. A scalar with no anchor is a working value. It keeps full precision, and it needs no declaration.
### Rounding happens where a value is named
Rounding happens where a value is named
A declared width is not only a display setting. The value is rounded, half-up, at the binding, and every formula that reads it gets the rounded value. Here is the same third, once unnamed and once named at two decimals:
A declared width is not only a display setting. The value is rounded, half-up, at the binding, and every formula that reads it gets the rounded value. Here is the same third, once unnamed and once named at two decimals:
````markdown ```vmark #s exact = 10 / 3 named precision 2 = 10 / 3 from_exact precision 2 = exact * 3 from_named precision 2 = named * 3 ``` From the exact third: **10.00**<!--vmark=s.from_exact-->. From the named one: **9.99**<!--vmark=s.from_named-->. ````
```vmark #s
exact = 10 / 3
named precision 2 = 10 / 3
from_exact precision 2 = exact * 3
from_named precision 2 = named * 3
```
From the exact third: **10.00**<!--vmark=s.from_exact-->. From the named one:
**9.99**<!--vmark=s.from_named-->.
`exact` has no anchor, so it keeps every digit, and three of it is `10.00`. `named` was rounded to `3.33` when it was named, and three of that is `9.99`.
exact has no anchor, so it keeps every digit, and three of it is 10.00.
named was rounded to 3.33 when it was named, and three of that is 9.99.
Both answers are correct. They answer different questions. So choose the width of an intermediate value on purpose. On an invoice, rounding each line's VAT to the grosz *before* adding the lines up is often what the accountant expects. In an engineering calculation, it is usually a mistake.
Both answers are correct. They answer different questions. So choose the width of an intermediate value on purpose. On an invoice, rounding each line's VAT to the grosz before adding the lines up is often what the accountant expects. In an engineering calculation, it is usually a mistake.
### Declare it even when you do not have to
Declare it even when you do not have to
Multiplication adds widths. That surprises people:
Multiplication adds widths. That surprises people:
``` vat = total * vat_rate ```
vat = total * vat_rate
`total` has 2 decimals, `vat_rate` (`23%`) has 2, so `vat` is written with **four**: `36.3400`. That is arithmetically honest and, on an invoice, wrong.
total has 2 decimals, vat_rate (23%) has 2, so vat is written with
four: 36.3400. That is arithmetically honest and, on an invoice, wrong.
Two ways to fix it, and the second is usually better:
Two ways to fix it, and the second is usually better:
``` vat precision 2 = total * vat_rate # declare the width VAT = ROUND(Net * vat_rate, 2) # round on purpose ```
vat precision 2 = total * vat_rate # declare the width
VAT = ROUND(Net * vat_rate, 2) # round on purpose
Prefer `ROUND` on money. It makes the rounding a stated decision in the document, rather than a display width.
Prefer ROUND on money. It makes the rounding a stated decision in the
document, rather than a display width.
Declaring a width electively is good practice anywhere a column holds money and must stay at two decimals even if an input later gains a third.
Declaring a width electively is good practice anywhere a column holds money and must stay at two decimals even if an input later gains a third.
### The ceiling
The ceiling
`N` stops at 18. The engine works with 40 significant digits, so a value with a very long whole part cannot also carry 18 decimals. The tool reports that instead of printing digits it never computed:
N stops at 18. The engine works with 40 significant digits, so a value with
a very long whole part cannot also carry 18 decimals. The tool reports that
instead of printing digits it never computed:
```console PRECISION s.big this value is too large to carry 18 decimals: 18 decimals past its integer digits exceeds the 40-significant-digit working precision ```
PRECISION s.big this value is too large to carry 18 decimals: 18 decimals past its integer digits exceeds the 40-significant-digit working precision
No document about money comes close to this. It exists so that the tool never prints a number it cannot stand behind.
No document about money comes close to this. It exists so that the tool never prints a number it cannot stand behind.
### Prose never decides a width
Prose never decides a width
Writing `**0**` in front of an anchor does not request zero decimals. The anchor is an output. If you want fewer decimals, change the binding.
Writing **0** in front of an anchor does not request zero decimals. The anchor
is an output. If you want fewer decimals, change the binding.
Two later features build on the binding's width. Percent display in prose shows `N − 2` decimals (chapter 15). And a `param`, a value that a what-if run may replace, must always declare its width (chapter 29).
Two later features build on the binding's width. Percent display in prose shows
N − 2 decimals (chapter 15). And a param, a value that a what-if run may
replace, must always declare its width (chapter 29).
## 15. Percent: a value, and a way to print one
15. Percent: a value, and a way to print one
A percent shows up in two different places, and they work differently. In a formula or a cell, `23%` is a **value**. In a sentence, `39.88%` is a **way of printing** a value. Keep the two apart and nothing here is surprising.
A percent shows up in two different places, and they work differently. In a
formula or a cell, 23% is a value. In a sentence, 39.88% is a way of
printing a value. Keep the two apart and nothing here is surprising.
### In a formula or a cell, a percent is a number
In a formula or a cell, a percent is a number
`23%` is a number exactly equal to `0.23`. It is not a display setting.
23% is a number exactly equal to 0.23. It is not a display setting.
``` vat_rate = 23% ```
vat_rate = 23%
An input cell may hold `23%` too, and it is read the same way. `%` is not a unit (chapter 16), so it is never carried through a formula. `Net * 23%` is simply `Net * 0.23`.
An input cell may hold 23% too, and it is read the same way. % is not a
unit (chapter 16), so it is never carried through a formula. Net * 23% is
simply Net * 0.23.
Its width is the written decimals plus two: `23%` is 2 decimals, `12.5%` is 3 (chapter 14).
Its width is the written decimals plus two: 23% is 2 decimals, 12.5% is 3
(chapter 14).
### A computed cell is always written as a decimal
A computed cell is always written as a decimal
Suppose you want a column that shows each line's share of the total, and you write the placeholders as percentages:
Suppose you want a column that shows each line's share of the total, and you write the placeholders as percentages:
````markdown | Stage | Net | Cost | Share | |-----------|---------:|---------:|-------:| | Discovery | 5400.00 | 3100.00 | 0.0% | | Mapping | 11900.00 | 7300.00 | 0.0% | ```vmark #lines Share precision 4 = Net / net_total net_total = SUM(Net) cost_total = SUM(Cost) margin precision 4 = (net_total - cost_total) / net_total ``` ````
| Stage | Net | Cost | Share |
|-----------|---------:|---------:|-------:|
| Discovery | 5400.00 | 3100.00 | 0.0% |
| Mapping | 11900.00 | 7300.00 | 0.0% |
```vmark #lines
Share precision 4 = Net / net_total
net_total = SUM(Net)
cost_total = SUM(Cost)
margin precision 4 = (net_total - cost_total) / net_total
```
`fmt` writes `0.3121` and `0.6879` into `Share`. A computed cell is always a plain decimal. Percent display exists only in prose.
fmt writes 0.3121 and 0.6879 into Share. A computed cell is always a
plain decimal. Percent display exists only in prose.
### In a sentence: add `%` to the anchor
In a sentence: add % to the anchor
*New after 0.1.6: this ships in the next release.*
New after 0.1.6: this ships in the next release.
Put `%` directly after the name inside the anchor comment:
Put % directly after the name inside the anchor comment:
```markdown The engagement clears a margin of **0**<!--vmark=lines.margin%-->, or **0**<!--vmark=lines.margin--> as a ratio. ```
The engagement clears a margin of **0**<!--vmark=lines.margin%-->, or
**0**<!--vmark=lines.margin--> as a ratio.
```console $ visimark fmt margin.md margin.md: updated 2 cells, 2 anchors ```
$ visimark fmt margin.md
margin.md: updated 2 cells, 2 anchors
```markdown The engagement clears a margin of **39.88%**<!--vmark=lines.margin%-->, or **0.3988**<!--vmark=lines.margin--> as a ratio. ```
The engagement clears a margin of **39.88%**<!--vmark=lines.margin%-->, or
**0.3988**<!--vmark=lines.margin--> as a ratio.
Both anchors show the same stored value, `0.3988`. The `%` is a request for one span only: *print this one as a percent*. Nothing else changes — not the stored value, not any formula, not what `eval` reports.
Both anchors show the same stored value, 0.3988. The % is a request for
one span only: print this one as a percent. Nothing else changes — not the
stored value, not any formula, not what eval reports.
The rule is simple. `fmt` multiplies the stored value by 100, writes it with **two fewer decimals than the binding's width**, and adds `%`:
The rule is simple. fmt multiplies the stored value by 100, writes it with
two fewer decimals than the binding's width, and adds %:
| Binding | Stored | Written with `%` | |---|---|---| | `margin precision 4 = …` | `0.3988` | `39.88%` | | `rate precision 3 = 12.5%` | `0.125` | `12.5%` | | `share precision 2 = …` | `0.40` | `40%` | | `loss precision 2 = -5%` | `-0.05` | `-5%` | | `over precision 2 = 150%` | `1.50` | `150%` |
| Binding | Stored | Written with % |
|---|---|---|
margin precision 4 = … |
0.3988 |
39.88% |
rate precision 3 = 12.5% |
0.125 |
12.5% |
share precision 2 = … |
0.40 |
40% |
loss precision 2 = -5% |
-0.05 |
-5% |
over precision 2 = 150% |
1.50 |
150% |
So the width of the binding decides how many decimals the percent shows. If you want `39.9%`, declare `precision 3`. Do not edit the text in front of the anchor.
So the width of the binding decides how many decimals the percent shows. If you
want 39.9%, declare precision 3. Do not edit the text in front of the
anchor.
### How `check` reads it
How check reads it
`check` compares numbers, not spellings. `39.88%` and `0.3988` are the same number, so either text is clean, with or without `%` on the anchor.
check compares numbers, not spellings. 39.88% and 0.3988 are the same
number, so either text is clean, with or without % on the anchor.
`fmt` is stricter: it always writes the anchor's own form. Here both spans agree with the stored `0.3988`, and `check` reports nothing, but `fmt` still rewrites them:
fmt is stricter: it always writes the anchor's own form. Here both spans
agree with the stored 0.3988, and check reports nothing, but fmt still
rewrites them:
```console $ tail -1 c.md A **0.3988**<!--vmark=s.m%-->. B **39.88%**<!--vmark=s.m-->. $ visimark fmt c.md c.md: updated 2 anchors $ tail -1 c.md A **39.88%**<!--vmark=s.m%-->. B **0.3988**<!--vmark=s.m-->. ```
$ tail -1 c.md
A **0.3988**<!--vmark=s.m%-->. B **39.88%**<!--vmark=s.m-->.
$ visimark fmt c.md
c.md: updated 2 anchors
$ tail -1 c.md
A **39.88%**<!--vmark=s.m%-->. B **0.3988**<!--vmark=s.m-->.
So if you type a percent by hand in front of an anchor without `%`, the next `fmt` turns it back into a decimal. The `%` belongs on the anchor, not in the text.
So if you type a percent by hand in front of an anchor without %, the next
fmt turns it back into a decimal. The % belongs on the anchor, not in the
text.
A wrong number is `STALE` as usual, and the report shows the percent form:
A wrong number is STALE as usual, and the report shows the percent form:
```console STALE s.rate 13.5% ≠ 12.5% ```
STALE s.rate 13.5% ≠ 12.5%
### What the `%` refuses
What the % refuses
| You wrote | Finding | |---|---| | `%` on a binding narrower than 2 decimals | `PRECISION` — `percent display needs precision 2 or more; a has 1` | | `%` on a date or a string | `TYPE` — `a % sigil is only legal on a numeric scalar` | | `%` on a chart image | `TYPE`, the same message | | `%` with a currency in the same span, such as `**$0.25**` | `UNIT` — `cannot mix a unit with percent display` | | a space before the `%`, as in `lines.margin %` | `ANCHOR` — the comment is malformed |
| You wrote | Finding |
|---|---|
% on a binding narrower than 2 decimals |
PRECISION — percent display needs precision 2 or more; a has 1 |
% on a date or a string |
TYPE — a % sigil is only legal on a numeric scalar |
% on a chart image |
TYPE, the same message |
% with a currency in the same span, such as **$0.25** |
UNIT — cannot mix a unit with percent display |
a space before the %, as in lines.margin % |
ANCHOR — the comment is malformed |
`fmt` leaves a span alone while any of these is reported.
fmt leaves a span alone while any of these is reported.
Two things the tools never do: `fmt` never adds `%` to an anchor you wrote without one, and `infer --write` never proposes one. Whether a ratio reads better as a percent is your call.
Two things the tools never do: fmt never adds % to an anchor you wrote
without one, and infer --write never proposes one. Whether a ratio reads
better as a percent is your call.
### A percent in a what-if
A percent in a what-if
A percent value also matters in Part 8. A `param` whose default is written as a percent, such as `param vat_rate precision 2 = default 23%`, only accepts a percent from a scenario. The capstone (chapter 31) uses such a `param` and prints it with a `%` anchor.
A percent value also matters in Part 8. A param whose default is written as a
percent, such as param vat_rate precision 2 = default 23%, only accepts a
percent from a scenario. The capstone (chapter 31) uses such a param and
prints it with a % anchor.
## 16. Currency and units
16. Currency and units
A currency or unit is decoration around a number. It is not part of the value, and `%` is not one of them (chapter 15). In prose, keep it outside the anchor (chapter 9).
A currency or unit is decoration around a number. It is not part of the value,
and % is not one of them (chapter 15). In prose, keep it outside the anchor
(chapter 9).
### Decoration in a cell
Decoration in a cell
A column may carry a currency symbol or a unit:
A column may carry a currency symbol or a unit:
```markdown | Item | Price | Qty | Net | |---------|-------:|----:|-------:| | Widgets | $12.50 | 4 | $50.00 | | Gadgets | $30.00 | 2 | $60.00 | ```
| Item | Price | Qty | Net |
|---------|-------:|----:|-------:|
| Widgets | $12.50 | 4 | $50.00 |
| Gadgets | $30.00 | 2 | $60.00 |
VisiMark strips the `$` to compute, and puts it back when it writes. The decoration is **inert**: it is never converted, never propagated through a formula, and never given meaning.
VisiMark strips the $ to compute, and puts it back when it writes. The
decoration is inert: it is never converted, never propagated through a
formula, and never given meaning.
### One column means one thing
One column means one thing
A column holding both `$5.00` and `€4.00` is an error, not a sum:
A column holding both $5.00 and €4.00 is an error, not a sum:
```console
UNIT s.Price · b "€4.00"
column mixes units: $ and €
``` UNIT s.Price · b "€4.00"
column mixes units: $ and €
This is not fussiness. Two currencies in one column have no total, and guessing one would be worse than refusing.
This is not fussiness. Two currencies in one column have no total, and guessing one would be worse than refusing.
### Thousands separators are refused
Thousands separators are refused
`1,800.00` does not parse. Write `1800.00`. A comma means different things in different countries, and no amount of care catches that by reading.
1,800.00 does not parse. Write 1800.00. A comma means different things in
different countries, and no amount of care catches that by reading.
## 17. Dates
17. Dates
A date is `YYYY-MM-DD`. Ten characters. ISO 8601. Nothing else is a date.
A date is YYYY-MM-DD. Ten characters. ISO 8601. Nothing else is a date.
```markdown | Milestone | Due | |-----------|------------| | Signature | 2026-10-01 | ```
| Milestone | Due |
|-----------|------------|
| Signature | 2026-10-01 |
### Why the tool refuses the alternatives
Why the tool refuses the alternatives
```console
DATE s.Due · One "15.10.2026"
Dates must be ISO 8601 calendar dates: YYYY-MM-DD.
Unambiguous — `visimark fmt --fix-dates` rewrites it to 2026-10-15.
``` DATE s.Due · One "15.10.2026"
Dates must be ISO 8601 calendar dates: YYYY-MM-DD.
Unambiguous — `visimark fmt --fix-dates` rewrites it to 2026-10-15.
`15.10.2026` has only one reading, because 15 cannot be a month. The tool offers to fix it:
15.10.2026 has only one reading, because 15 cannot be a month. The tool offers
to fix it:
```console $ visimark fmt --fix-dates invoice.md ```
$ visimark fmt --fix-dates invoice.md
This one is different:
This one is different:
```console
DATE s.Due · One "11/12/2026"
Dates must be ISO 8601 calendar dates: YYYY-MM-DD.
Ambiguous: 2026-12-11 or 2026-11-12, 29 days apart. Fix by hand.
``` DATE s.Due · One "11/12/2026"
Dates must be ISO 8601 calendar dates: YYYY-MM-DD.
Ambiguous: 2026-12-11 or 2026-11-12, 29 days apart. Fix by hand.
`11/12/2026` is 11 December in London and 12 November in Chicago. The two readings are 29 days apart, which is the difference between paying on time and paying late. No tool can decide that from the text, so VisiMark refuses, and `--fix-dates` leaves it alone.
11/12/2026 is 11 December in London and 12 November in Chicago. The two
readings are 29 days apart, which is the difference between paying on time and
paying late. No tool can decide that from the text, so VisiMark refuses, and
--fix-dates leaves it alone.
### Arithmetic on dates
Arithmetic on dates
- `date - date` is a whole number of days. - `date + n` and `date - n` give another date. - `MIN` and `MAX` work over a column of dates. - `EOMONTH(d, months)` gives the last day of a month, which is how payment terms are usually written.
date - dateis a whole number of days.date + nanddate - ngive another date.MINandMAXwork over a column of dates.EOMONTH(d, months)gives the last day of a month, which is how payment terms are usually written.
A worked example:
A worked example:
````markdown | Task | Start | End | Days | Late | |-------|------------|------------|-----:|-----:| | Alpha | 2026-01-05 | 2026-02-10 | 36 | 1 | | Beta | 2026-02-01 | 2026-03-20 | 47 | 1 | | Gamma | 2026-03-01 | 2026-03-08 | 7 | 0 | ```vmark #plan Days = End - Start Late = IF(Days > 30, 1, 0) longest = MAX(Days) late_count = SUM(Late) ``` ````
| Task | Start | End | Days | Late |
|-------|------------|------------|-----:|-----:|
| Alpha | 2026-01-05 | 2026-02-10 | 36 | 1 |
| Beta | 2026-02-01 | 2026-03-20 | 47 | 1 |
| Gamma | 2026-03-01 | 2026-03-08 | 7 | 0 |
```vmark #plan
Days = End - Start
Late = IF(Days > 30, 1, 0)
longest = MAX(Days)
late_count = SUM(Late)
```
Every number in `Days` and `Late` was written by `fmt`.
Every number in Days and Late was written by fmt.
## 18. Headers that are not names
18. Headers that are not names
A column rule's name is normally the header text itself, so it normally has to be a plain identifier. Real headers are not:
A column rule's name is normally the header text itself, so it normally has to be a plain identifier. Real headers are not:
```markdown | Stage | Effort (man-days) | ```
| Stage | Effort (man-days) |
The wrong fix is to rename the header. That header is human-facing prose, somebody chose those words, and rewriting it to `Effort` to please a tool is exactly the kind of intrusion this format tries to avoid.
The wrong fix is to rename the header. That header is human-facing prose,
somebody chose those words, and rewriting it to Effort to please a tool is
exactly the kind of intrusion this format tries to avoid.
Two forms handle it, and neither touches the table.
Two forms handle it, and neither touches the table.
### `"Header text" is symbol` — give the column a short name
"Header text" is symbol — give the column a short name
``` "Effort (man-days)" is days Net = days * Rate effort_total = SUM(days) ```
"Effort (man-days)" is days
Net = days * Rate
effort_total = SUM(days)
The alias works everywhere the column's own name would: as an operand, inside a reduce, and as the left side of a rule. It creates no second column.
The alias works everywhere the column's own name would: as an operand, inside a reduce, and as the left side of a rule. It creates no second column.
This is the **only** way to *read* such a column in a formula. A bare quoted string is already a string literal, so it cannot double as an operand.
This is the only way to read such a column in a formula. A bare quoted string is already a string literal, so it cannot double as an operand.
### `"Header text" = expr` — write the column directly
"Header text" = expr — write the column directly
``` "GPU-to-GPU Bandwidth (GB/s)" = ROUND(bpu / GPUs * 1000, 0) ```
"GPU-to-GPU Bandwidth (GB/s)" = ROUND(bpu / GPUs * 1000, 0)
Use this when you only produce that column and never read it back.
Use this when you only produce that column and never read it back.
### Matching is exact
Matching is exact
The quoted text must be byte-for-byte identical to the header cell's printed text. No trimming, no case folding. If it does not match any header, that is an `UNDEF` error with a suggestion — never a silently created scalar.
The quoted text must be byte-for-byte identical to the header cell's printed
text. No trimming, no case folding. If it does not match any header, that is an
UNDEF error with a suggestion — never a silently created scalar.
## 19. Assertions: facts that must stay true
19. Assertions: facts that must stay true
Some things are not numbers to compute, but statements that must hold. A schedule must cover the invoice. Shares must add to 100%. A span must stay inside a limit.
Some things are not numbers to compute, but statements that must hold. A schedule must cover the invoice. Shares must add to 100%. A span must stay inside a limit.
``` assert variance == 0 assert ROUND(SUM(Share), 2) == 1 assert delivery >= signature ```
assert variance == 0
assert ROUND(SUM(Share), 2) == 1
assert delivery >= signature
An `assert` line lives in a `#id` block. It stores nothing, `fmt` never touches it, and a false one is an error:
An assert line lives in a #id block. It stores nothing, fmt never touches
it, and a false one is an error:
```console
ASSERT #recon variance == 0
-50.00 == 0 is false
``` ASSERT #recon variance == 0
-50.00 == 0 is false
Note the second line. The report prints the expression, and then the same expression with the named values filled in. You see *why* it failed without opening the file.
Note the second line. The report prints the expression, and then the same expression with the named values filled in. You see why it failed without opening the file.
### Rules for assertions
Rules for assertions
**Scalar expressions only.** To check something about every row, use an aggregate: `assert MIN(margin) >= 0` means "no row has a negative margin".
Scalar expressions only. To check something about every row, use an
aggregate: assert MIN(margin) >= 0 means "no row has a negative margin".
**There is no rounding tolerance.** If you need one, write it:
There is no rounding tolerance. If you need one, write it:
``` assert |variance| <= 0.05 ```
assert |variance| <= 0.05
That is better than a hidden tolerance, because the number `0.05` is on the page where a reviewer can argue with it.
That is better than a hidden tolerance, because the number 0.05 is on the page
where a reviewer can argue with it.
### When to reach for one
When to reach for one
Whenever a number's correctness depends on a relationship that a later edit could quietly break. Rounding each instalment of a payment schedule can leave a few cents of remainder — an assertion is how you state which remainder is acceptable.
Whenever a number's correctness depends on a relationship that a later edit could quietly break. Rounding each instalment of a payment schedule can leave a few cents of remainder — an assertion is how you state which remainder is acceptable.
Here is the one from [`tutorial/capstone.md`](tutorial/capstone.md):
Here is the one from tutorial/capstone.md:
````markdown ```vmark #recon invoiced = lines.gross_total scheduled = schedule.covered variance = scheduled - invoiced assert |variance| <= 0.05 ``` ````
```vmark #recon
invoiced = lines.gross_total
scheduled = schedule.covered
variance = scheduled - invoiced
assert |variance| <= 0.05
```
An assertion is also the only construct in the language that states an argument rather than a number. That makes it the most valuable thing in the file during review.
An assertion is also the only construct in the language that states an argument rather than a number. That makes it the most valuable thing in the file during review.
---
# Part 5 — Documents you did not write
Part 5 — Documents you did not write
## 20. `infer`: wiring up a table that already has numbers
20. infer: wiring up a table that already has numbers
Most adoption does not start with an empty file. It starts with a quote, a budget or an estimate that somebody already wrote the ordinary way. All the numbers are there. None of them are connected.
Most adoption does not start with an empty file. It starts with a quote, a budget or an estimate that somebody already wrote the ordinary way. All the numbers are there. None of them are connected.
Do **not** type the rules out by hand. Ask the tool what the numbers already imply:
Do not type the rules out by hand. Ask the tool what the numbers already imply:
```console
$ visimark infer quote.md
quote.md table at line 3 — 4 rows, 4 columns
column rules
Revenue = Seats * Fee 4/4 rows
scalars matching figures in prose
72 line 10 = SUM(Seats) seats_total
27600.00 line 10 = SUM(Revenue) revenue_total
no rule found — treating as inputs
Module, Seats, Fee
1 rule, 0 aliases, 2 scalars, 2 anchors.
```$ visimark infer quote.md
quote.md table at line 3 — 4 rows, 4 columns
column rules
Revenue = Seats * Fee 4/4 rows
scalars matching figures in prose
72 line 10 = SUM(Seats) seats_total
27600.00 line 10 = SUM(Revenue) revenue_total
no rule found — treating as inputs
Module, Seats, Fee
1 rule, 0 aliases, 2 scalars, 2 anchors.
`infer` reads the tables and the prose, and works out which rules reproduce the numbers that are already there.
infer reads the tables and the prose, and works out which rules reproduce the
numbers that are already there.
### Rules are verified, never fitted
Rules are verified, never fitted
A candidate rule either reproduces **every** cell of its column, at that column's own precision, or it is not a candidate. There is no score, no threshold and no best guess.
A candidate rule either reproduces every cell of its column, at that column's own precision, or it is not a candidate. There is no score, no threshold and no best guess.
A constant is solved from the first row and then checked against all the others. A constant that only satisfies the row it came from is not a finding.
A constant is solved from the first row and then checked against all the others. A constant that only satisfies the row it came from is not a finding.
### The most valuable output is a near-miss
The most valuable output is a near-miss
Transpose two digits in one cell and ask again:
Transpose two digits in one cell and ask again:
```console
$ visimark infer quote.md
near-miss — not proposed
Delivered = Revenue + Materials 3/4 rows
row 3 Coaching
cell 5823.00, rule gives 5832.00 differs by 9.00
```$ visimark infer quote.md
near-miss — not proposed
Delivered = Revenue + Materials 3/4 rows
row 3 Coaching
cell 5823.00, rule gives 5832.00 differs by 9.00
Read what that means. The tool has just told you the document **has a wrong number in it** — to someone who has adopted nothing, written no formulas and learned no syntax.
Read what that means. The tool has just told you the document has a wrong number in it — to someone who has adopted nothing, written no formulas and learned no syntax.
A near-miss is never proposed and never written, even with `--write`. It is reported, with the row named and the difference given, and what to do about it is your decision.
A near-miss is never proposed and never written, even with --write. It is
reported, with the row named and the difference given, and what to do about it
is your decision.
### "Also fits, not proposed"
"Also fits, not proposed"
Sometimes two rules both reproduce a column exactly:
Sometimes two rules both reproduce a column exactly:
```console
also fits, not proposed
Delivered = Revenue * 1.08 prefers a rule over materialised columns
Delivered = Materials * 13.5 prefers a rule over materialised columns
``` also fits, not proposed
Delivered = Revenue * 1.08 prefers a rule over materialised columns
Delivered = Materials * 13.5 prefers a rule over materialised columns
`Delivered = Revenue + Materials` wins, because a rule built from columns that are on the page beats one that introduces a constant. Every intermediate stays a number the reviewer can see. The losers are listed rather than dropped, because choosing between them is a judgment call and you should see it being made.
Delivered = Revenue + Materials wins, because a rule built from columns that
are on the page beats one that introduces a constant. Every intermediate stays a
number the reviewer can see. The losers are listed rather than dropped, because
choosing between them is a judgment call and you should see it being made.
### "Weak, not written"
"Weak, not written"
```console
Revenue = Seats * Fee 2 rows — weak, not written
``` Revenue = Seats * Fee 2 rows — weak, not written
Two rows are not enough evidence. Almost any rule fits two rows. `infer` says so and declines.
Two rows are not enough evidence. Almost any rule fits two rows. infer says so
and declines.
### `--write` only inserts
--write only inserts
```console $ visimark infer quote.md --write ```
$ visimark infer quote.md --write
It inserts a `vmark` block after each table, and an anchor after each matched figure. It never rewrites an existing byte. Your prose, your headings and every input column survive untouched.
It inserts a vmark block after each table, and an anchor after each matched
figure. It never rewrites an existing byte. Your prose, your headings and every
input column survive untouched.
It also writes the `<!--vmark:no-formulas-->` marker — but only when it found **nothing whatsoever** to derive. If it found a near-miss, or two rules it could not choose between, it refuses to write the marker, because those mean the document does have arithmetic and wants a person to look at it.
It also writes the <!--vmark:no-formulas--> marker — but only when it found
nothing whatsoever to derive. If it found a near-miss, or two rules it could
not choose between, it refuses to write the marker, because those mean the
document does have arithmetic and wants a person to look at it.
### What it will not do for you
What it will not do for you
A figure has to be an inline element — usually `**bold**` — for an anchor to be proposed. A bare number inside a plain sentence is reported as `no anchorable inline node holds this figure`, and you add the emphasis yourself.
A figure has to be an inline element — usually **bold** — for an anchor to be
proposed. A bare number inside a plain sentence is reported as
no anchorable inline node holds this figure, and you add the emphasis yourself.
`infer` never invents names from your prose. `SUM(Revenue)` becomes `revenue_total`, mechanically. Reading the sentence and calling it `teaching_revenue` would read better and would be a guess about meaning. Rename it yourself afterwards; it is one edit.
infer never invents names from your prose. SUM(Revenue) becomes
revenue_total, mechanically. Reading the sentence and calling it
teaching_revenue would read better and would be a guess about meaning. Rename
it yourself afterwards; it is one edit.
Tables with no name get `#unnamed1`, `#unnamed2`. They read as placeholders, which is the point.
Tables with no name get #unnamed1, #unnamed2. They read as placeholders,
which is the point.
### The adoption path, in full
The adoption path, in full
```console $ visimark check quote.md # COVERAGE: nothing here is checked $ visimark infer quote.md # read the proposal. Look hard at near-misses. $ visimark infer quote.md --write $ visimark check quote.md # 0 problems $ sed -i 's/| 24 |/| 30 |/' quote.md $ visimark check quote.md # MUST fail now. If it does not, nothing is wired. $ git checkout quote.md ```
$ visimark check quote.md # COVERAGE: nothing here is checked
$ visimark infer quote.md # read the proposal. Look hard at near-misses.
$ visimark infer quote.md --write
$ visimark check quote.md # 0 problems
$ sed -i 's/| 24 |/| 30 |/' quote.md
$ visimark check quote.md # MUST fail now. If it does not, nothing is wired.
$ git checkout quote.md
## 21. Reading `check`: every finding
21. Reading check: every finding
Findings come in two classes. **Problems** are counted in the `N problems` line and make the run fail. **Advice** is printed and costs nothing.
Findings come in two classes. Problems are counted in the N problems line
and make the run fail. Advice is printed and costs nothing.
### The problems
The problems
**`STALE` — a stored number disagrees with its formula.**
STALE — a stored number disagrees with its formula.
```console STALE order.Net · Widgets 50.00 ≠ 75.00 Qty * Price ```
STALE order.Net · Widgets 50.00 ≠ 75.00 Qty * Price
This is the ordinary case, and the only one `fmt` repairs. It also covers a chart file that no longer matches its data, including one that is missing.
This is the ordinary case, and the only one fmt repairs. It also covers a
chart file that no longer matches its data, including one that is missing.
**`COVERAGE` — nothing in this document is checked.**
COVERAGE — nothing in this document is checked.
```console
COVERAGE a table with no `vmark` rules — nothing in this document is checked
run `visimark infer` to derive them, or mark it `<!--vmark:no-formulas-->`
``` COVERAGE a table with no `vmark` rules — nothing in this document is checked
run `visimark infer` to derive them, or mark it `<!--vmark:no-formulas-->`
See chapter 5. Also reported when a document carries a `no-formulas` marker that its own rules now contradict.
See chapter 5. Also reported when a document carries a no-formulas marker that
its own rules now contradict.
**`DATE` — a date is not ISO 8601.** See chapter 17. `fmt --fix-dates` fixes the unambiguous ones.
DATE — a date is not ISO 8601. See chapter 17. fmt --fix-dates fixes the
unambiguous ones.
**`UNIT` — one column means two things.**
UNIT — one column means two things.
```console
UNIT s.Price · b "€4.00"
column mixes units: $ and €
``` UNIT s.Price · b "€4.00"
column mixes units: $ and €
**`UNDEF` — a formula names something that does not exist.**
UNDEF — a formula names something that does not exist.
```console
UNDEF s.Net unknown name `Prise`
did you mean `Price`?
``` UNDEF s.Net unknown name `Prise`
did you mean `Price`?
**`DUP` — the same name is bound twice in one scope.**
DUP — the same name is bound twice in one scope.
```console
DUP s.total `total` is already defined in this scope
the first binding wins; delete or rename one of them
``` DUP s.total `total` is already defined in this scope
the first binding wins; delete or rename one of them
The first binding wins, which is exactly why this is an error and not a silent overwrite.
The first binding wins, which is exactly why this is an error and not a silent overwrite.
**`VECTOR` — a column was used where one value is required.**
VECTOR — a column was used where one value is required.
```console
VECTOR p.Amount `s.Net` is a column, not a value.
Wrap it in an aggregate: SUM(s.Net)
``` VECTOR p.Amount `s.Net` is a column, not a value.
Wrap it in an aggregate: SUM(s.Net)
**`CYCLE` — values depend on each other in a circle.**
CYCLE — values depend on each other in a circle.
```console CYCLE s.base → s.total → s.fee → s.base ```
CYCLE s.base → s.total → s.fee → s.base
The whole path is printed, so you can see where to cut it.
The whole path is printed, so you can see where to cut it.
**`TYPE` — something cannot go where it was asked to go.**
TYPE — something cannot go where it was asked to go.
```console TYPE plan.Flag a boolean cannot be stored; wrap it in `IF()` to produce a number or a string ```
TYPE plan.Flag a boolean cannot be stored; wrap it in `IF()` to produce a number or a string
Other causes: a division by zero, a mismatched bracket such as `⌊x⌉` (chapter 12), a `%` anchor on a date or a string (chapter 15), and a `param` whose default is not a plain number (chapter 29). A formula that does not parse at all is reported as `TYPE` too.
Other causes: a division by zero, a mismatched bracket such as ⌊x⌉
(chapter 12), a % anchor on a date or a string (chapter 15), and a param
whose default is not a plain number (chapter 29). A formula that does not parse
at all is reported as TYPE too.
**`SHEET` — a block's relationship to its table is broken.**
SHEET — a block's relationship to its table is broken.
```console SHEET s. this block declares column rules but no table immediately precedes it ```
SHEET s. this block declares column rules but no table immediately precedes it
Usually a paragraph wandered between the table and the block.
Usually a paragraph wandered between the table and the block.
**`PRECISION` — a binding writes numbers but has no width.**
PRECISION — a binding writes numbers but has no width.
```console
PRECISION s.per_item `total / COUNT(Net)` has no derivable precision
declare the width: `per_item precision N = …`
``` PRECISION s.per_item `total / COUNT(Net)` has no derivable precision
declare the width: `per_item precision N = …`
Also reported for a `%` anchor on a binding narrower than two decimals (chapter 15), a `param` with no `precision` clause or a default wider than it (chapter 29), and a value too large to carry its declared decimals (chapter 14).
Also reported for a % anchor on a binding narrower than two decimals
(chapter 15), a param with no precision clause or a default wider than it
(chapter 29), and a value too large to carry its declared decimals
(chapter 14).
**`ASSERT` — an `assert` statement is false.**
ASSERT — an assert statement is false.
```console
ASSERT #recon variance == 0
-50.00 == 0 is false
``` ASSERT #recon variance == 0
-50.00 == 0 is false
**`ANCHOR` — an anchor has nothing it can rewrite**, or a comment that announces itself as an anchor does not parse. A hyphenated sheet id, a stray space (also before a `%`) or an empty name is reported rather than silently ignored.
ANCHOR — an anchor has nothing it can rewrite, or a comment that announces
itself as an anchor does not parse. A hyphenated sheet id, a stray space (also
before a %) or an empty name is reported rather than silently ignored.
**`IMPORT` — a declared CSV import cannot be resolved.** See chapter 23.
IMPORT — a declared CSV import cannot be resolved. See chapter 23.
**`ARTIFACT` — a declared chart cannot be built or written.** A pie of negative values, a series that is blank or not numeric, an unknown chart type, or a path outside the document's directory. See chapter 24.
ARTIFACT — a declared chart cannot be built or written. A pie of negative
values, a series that is blank or not numeric, an unknown chart type, or a path
outside the document's directory. See chapter 24.
### The advice
The advice
**`WARN` — something is defined and never read.**
WARN — something is defined and never read.
```console WARN order.count defined and never read — did you mean `Net`? ```
WARN order.count defined and never read — did you mean `Net`?
Usually a typo on the left-hand side of a rule. See chapter 8.
Usually a typo on the left-hand side of a rule. See chapter 8.
**`NOTE` — something could not be verified, because something it depends on is broken.**
NOTE — something could not be verified, because something it depends on is
broken.
```console NOTE schedule.Days · 2 rows not verified (upstream DATE errors) ```
NOTE schedule.Days · 2 rows not verified (upstream DATE errors)
It disappears when the real problem above it is fixed.
It disappears when the real problem above it is fixed.
### Which ones can a tool fix?
Which ones can a tool fix?
| Finding | Fixed by | |---|---| | `STALE` | `visimark fmt` | | `DATE` | `fmt --fix-dates` when unambiguous, otherwise by hand | | `IMPORT`, missing stamp only | `visimark fmt` | | `COVERAGE` | `visimark infer`, or the marker if it is honest | | `PRECISION` | by hand — `infer` can propose the clause | | everything else | by hand |
| Finding | Fixed by |
|---|---|
STALE |
visimark fmt |
DATE |
fmt --fix-dates when unambiguous, otherwise by hand |
IMPORT, missing stamp only |
visimark fmt |
COVERAGE |
visimark infer, or the marker if it is honest |
PRECISION |
by hand — infer can propose the clause |
| everything else | by hand |
**`fmt` repairs stale values and nothing else.** Every other finding is a question only a person can answer. Do not paper over a `DATE`, `UNIT`, `CYCLE`, `UNDEF` or `DUP` finding by editing the number it points at. Changing the number a finding complains about is the one move that turns a caught error into a hidden one.
fmt repairs stale values and nothing else. Every other finding is a
question only a person can answer. Do not paper over a DATE, UNIT, CYCLE,
UNDEF or DUP finding by editing the number it points at. Changing the number
a finding complains about is the one move that turns a caught error into a
hidden one.
## 22. `explain`, and reviewing a VisiMark diff
22. explain, and reviewing a VisiMark diff
### `explain` answers "what is this document doing?"
explain answers "what is this document doing?"
```console
$ visimark explain docs/tutorial/order.md
#order
inputs: Item, Qty, Price
rules:
Net = Qty * Price precision 2 (derived)
scalars:
net_total = SUM(Net) precision 2 (derived)
line_count = COUNT(Item) precision 0 (derived)
avg_line = net_total / line_count precision 2 (declared)
order: Net → net_total → line_count → avg_line
#tax
inputs: Band, Rate
rules:
Base = order.net_total precision 2 (derived)
Tax = ROUND(Base * Rate, 2) precision 2 (derived)
scalars:
tax_total = SUM(Tax) precision 2 (derived)
gross_total = order.net_total + tax_total precision 2 (derived)
order: Base → Tax → tax_total → gross_total
assertions:
gross_total >= order.net_total
```$ visimark explain docs/tutorial/order.md
#order
inputs: Item, Qty, Price
rules:
Net = Qty * Price precision 2 (derived)
scalars:
net_total = SUM(Net) precision 2 (derived)
line_count = COUNT(Item) precision 0 (derived)
avg_line = net_total / line_count precision 2 (declared)
order: Net → net_total → line_count → avg_line
#tax
inputs: Band, Rate
rules:
Base = order.net_total precision 2 (derived)
Tax = ROUND(Base * Rate, 2) precision 2 (derived)
scalars:
tax_total = SUM(Tax) precision 2 (derived)
gross_total = order.net_total + tax_total precision 2 (derived)
order: Base → Tax → tax_total → gross_total
assertions:
gross_total >= order.net_total
Four things worth reading here:
Four things worth reading here:
- **`inputs`** — the columns nobody computes. These are the numbers a human is responsible for. In a review, this is the list you check against reality. - **`order`** — the evaluation order VisiMark worked out. - **`precision … (derived)` / `(declared)`** — whether a width followed from the arithmetic or you chose it. - **`assertions`** — the claims this document makes about itself.
inputs— the columns nobody computes. These are the numbers a human is responsible for. In a review, this is the list you check against reality.order— the evaluation order VisiMark worked out.precision … (derived)/(declared)— whether a width followed from the arithmetic or you chose it.assertions— the claims this document makes about itself.
A document with `param` lines also gets a `params:` list in each sheet: the assumptions a what-if run may change, with their widths and defaults (chapter 29).
A document with param lines also gets a params: list in each sheet: the
assumptions a what-if run may change, with their widths and defaults
(chapter 29).
Pass `#sheet` to limit it to one sheet.
Pass #sheet to limit it to one sheet.
### What to look at in review
What to look at in review
A VisiMark diff tells you more than an ordinary one, so review it differently.
A VisiMark diff tells you more than an ordinary one, so review it differently.
**Did an input change?** That is a human decision. Ask about it.
Did an input change? That is a human decision. Ask about it.
**Did a computed value change without an input or rule changing?** That should be impossible. If it happened, somebody hand-edited an output.
Did a computed value change without an input or rule changing? That should be impossible. If it happened, somebody hand-edited an output.
**Did a rule change?** This is the most important line in any VisiMark diff. A changed rule silently changes every number under it. Read it carefully.
Did a rule change? This is the most important line in any VisiMark diff. A changed rule silently changes every number under it. Read it carefully.
**Did the right things move together?** In the chapter 7 diff, one `Qty` change moved the net, the tax base, the tax and the amount due. If a quantity changes and the tax does not, something is not connected.
Did the right things move together? In the chapter 7 diff, one Qty change
moved the net, the tax base, the tax and the amount due. If a quantity changes
and the tax does not, something is not connected.
**Did an assertion get deleted or loosened?** `assert |variance| <= 0.05` becoming `<= 50.00` is a one-character-looking change that removes a real guarantee.
Did an assertion get deleted or loosened? assert |variance| <= 0.05
becoming <= 50.00 is a one-character-looking change that removes a real
guarantee.
Nothing outside the file can change a number. There are no plugins, no config file, no environment variables, no clock. So the diff really does contain everything.
Nothing outside the file can change a number. There are no plugins, no config file, no environment variables, no clock. So the diff really does contain everything.
---
# Part 6 — Beyond one file
Part 6 — Beyond one file
The core of VisiMark is finished at chapter 22. These two chapters are features you may never need. Read them when you do.
The core of VisiMark is finished at chapter 22. These two chapters are features you may never need. Read them when you do.
## 23. Rows from a CSV
23. Rows from a CSV
Sometimes the rows are produced by another system and it makes no sense to paste them into the document. A sheet can take its rows from a local CSV file instead of from a Markdown table.
Sometimes the rows are produced by another system and it makes no sense to paste them into the document. A sheet can take its rows from a local CSV file instead of from a Markdown table.
````markdown ```vmark #order from rows.csv labelled Item, Qty, Price, Net at sha256:618cac75ed467d26508037dac4fced2fa3853e712e9172bc0d15c69d2bb05412 total = SUM(Net) count = COUNT(Item) ``` The order totals **158.00**<!--vmark=order.total--> across **3**<!--vmark=order.count--> lines. ````
```vmark #order from rows.csv labelled Item, Qty, Price, Net at sha256:618cac75ed467d26508037dac4fced2fa3853e712e9172bc0d15c69d2bb05412
total = SUM(Net)
count = COUNT(Item)
```
The order totals **158.00**<!--vmark=order.total--> across **3**<!--vmark=order.count--> lines.
Three clauses on the fence line:
Three clauses on the fence line:
- **`from rows.csv`** — where the rows come from. The path must be inside the document's own directory. - **`labelled Item, Qty, Price, Net`** — asserts the CSV's header row, by name and in order. A column renamed or reordered upstream becomes a loud `IMPORT` finding instead of a silent misread. - **`at sha256:…`** — pins the exact bytes of the file.
from rows.csv— where the rows come from. The path must be inside the document's own directory.labelled Item, Qty, Price, Net— asserts the CSV's header row, by name and in order. A column renamed or reordered upstream becomes a loudIMPORTfinding instead of a silent misread.at sha256:…— pins the exact bytes of the file.
### Writing one
Writing one
Write the `from` and `labelled` clauses, leave the stamp out, and let `fmt` add it:
Write the from and labelled clauses, leave the stamp out, and let fmt add
it:
```console $ visimark check order.md order.md STALE order.total 0.00 ≠ 158.00 SUM(Net) STALE order.count 0 ≠ 3 COUNT(Item) STALE 2 prose anchors bound to the values above IMPORT #order unstamped import 5 problems (4 stale, 1 error) $ visimark fmt order.md order.md: updated 2 anchors ```
$ visimark check order.md
order.md
STALE order.total 0.00 ≠ 158.00 SUM(Net)
STALE order.count 0 ≠ 3 COUNT(Item)
STALE 2 prose anchors bound to the values above
IMPORT #order unstamped import
5 problems (4 stale, 1 error)
$ visimark fmt order.md
order.md: updated 2 anchors
`fmt` wrote the `at sha256:…` clause and filled the anchors.
fmt wrote the at sha256:… clause and filled the anchors.
### What the stamp buys you
What the stamp buys you
Change the CSV underneath the document and `check` notices:
Change the CSV underneath the document and check notices:
```console $ visimark check order.md order.md STALE order. `rows.csv` does not match its recorded stamp — expected sha256:618cac…, got sha256:ad5069… 1 problem (1 stale, 0 errors) ```
$ visimark check order.md
order.md
STALE order. `rows.csv` does not match its recorded stamp — expected sha256:618cac…, got sha256:ad5069…
1 problem (1 stale, 0 errors)
An inline table needs no such guard, because its data is in the same text `check` is already reading. An imported sheet has data that can move without the document changing, so the stamp is what keeps the document honest.
An inline table needs no such guard, because its data is in the same text
check is already reading. An imported sheet has data that can move without the
document changing, so the stamp is what keeps the document honest.
When the change is intended, `visimark fmt` re-stamps it. **VisiMark never writes to the CSV itself.**
When the change is intended, visimark fmt re-stamps it. VisiMark never
writes to the CSV itself.
### What changes when a sheet is imported
What changes when a sheet is imported
**There are no column rules.** An imported sheet's columns are read-only inputs taken from the CSV. There is no cell for a rule to write to. Only aggregates still run.
There are no column rules. An imported sheet's columns are read-only inputs taken from the CSV. There is no cell for a rule to write to. Only aggregates still run.
So if the CSV has a `Net` column, that `Net` was computed by whatever produced the CSV — not by VisiMark. VisiMark verifies the aggregate over it, and the stamp on the file, and nothing more.
So if the CSV has a Net column, that Net was computed by whatever produced
the CSV — not by VisiMark. VisiMark verifies the aggregate over it, and the
stamp on the file, and nothing more.
A binding that shadows an imported column is an `IMPORT` error.
A binding that shadows an imported column is an IMPORT error.
## 24. Charts as generated artifacts
24. Charts as generated artifacts
A `chart` statement declares a picture drawn from columns of its own sheet.
A chart statement declares a picture drawn from columns of its own sheet.
````markdown | Month | Revenue | Cost | Profit | |-------|---------:|---------:|---------:| | Jan | 48200.00 | 31100.00 | 17100.00 | | Feb | 51400.00 | 33250.00 | 18150.00 | | Mar | 46900.00 | 32800.00 | 14100.00 | ```vmark #sales Profit = Revenue - Cost total = SUM(Profit) chart trend as bar of Revenue, Cost labelled Month ``` <!--vmark=sales.trend--> ````
| Month | Revenue | Cost | Profit |
|-------|---------:|---------:|---------:|
| Jan | 48200.00 | 31100.00 | 17100.00 |
| Feb | 51400.00 | 33250.00 | 18150.00 |
| Mar | 46900.00 | 32800.00 | 14100.00 |
```vmark #sales
Profit = Revenue - Cost
total = SUM(Profit)
chart trend as bar of Revenue, Cost labelled Month
```
<!--vmark=sales.trend-->
The shape is `chart NAME as TYPE of SERIES… labelled LABELS`, with an optional `aspect 16:9`. Types include `bar`, `line`, `stacked-bar`, `pie` and `area`.
The shape is chart NAME as TYPE of SERIES… labelled LABELS, with an optional
aspect 16:9. Types include bar, line, stacked-bar, pie and area.
Like an aggregate, a chart takes bare column references, never expressions — so every value it draws is a number already visible on the page.
Like an aggregate, a chart takes bare column references, never expressions — so every value it draws is a number already visible on the page.
### The image anchor
The image anchor
The chart is an ordinary Markdown image carrying an anchor. The tool writes to exactly the path the image names. It never invents an image line, and a declaration with no image is an `ANCHOR` error.
The chart is an ordinary Markdown image carrying an anchor. The tool writes to
exactly the path the image names. It never invents an image line, and a
declaration with no image is an ANCHOR error.
### `fmt` draws it, `check` verifies it
fmt draws it, check verifies it
```console $ visimark check c.md STALE sales.Profit · Jan 0.00 ≠ 17100.00 Revenue - Cost STALE sales.trend artifact missing at `charts/c-trend.svg` $ visimark fmt c.md c.md: updated 3 cells, 1 artifact ```
$ visimark check c.md
STALE sales.Profit · Jan 0.00 ≠ 17100.00 Revenue - Cost
STALE sales.trend artifact missing at `charts/c-trend.svg`
$ visimark fmt c.md
c.md: updated 3 cells, 1 artifact
Change a `Revenue` cell and the SVG is `STALE` until `fmt` regenerates it. Delete the file and it is `STALE` as missing.
Change a Revenue cell and the SVG is STALE until fmt regenerates it.
Delete the file and it is STALE as missing.
### What `check` does and does not prove
What check does and does not prove
`check` proves the SVG on disk is **exactly what the current data renders to**. It does not prove the picture is a fair depiction of the numbers. An artifact's provenance is verifiable; its draughtsmanship is not.
check proves the SVG on disk is exactly what the current data renders to.
It does not prove the picture is a fair depiction of the numbers. An artifact's
provenance is verifiable; its draughtsmanship is not.
Charts belong on data worth looking at as a shape — a trend over months, a split across segments. An invoice does not need one.
Charts belong on data worth looking at as a shape — a trend over months, a split across segments. An invoice does not need one.
---
# Part 7 — Automation
Part 7 — Automation
This is the payoff. Everything so far was about making a document checkable. Now the check runs without you, and other programs read the document.
This is the payoff. Everything so far was about making a document checkable. Now the check runs without you, and other programs read the document.
## 25. In CI
25. In CI
The whole point of `check` is that it runs somewhere other than a human's judgment. This chapter is the short version; [`ci.md`](ci.md) is the long one, and covers annotations, pinning, other CI systems and how to roll this out on a repository that already has documents.
The whole point of check is that it runs somewhere other than a human's
judgment. This chapter is the short version;
ci.md is the long one, and covers annotations, pinning, other CI
systems and how to roll this out on a repository that already has documents.
The setup is one line:
The setup is one line:
```bash npx visimark check docs/*.md ```
npx visimark check docs/*.md
It exits non-zero on the first disagreement, which is all any CI system needs.
It exits non-zero on the first disagreement, which is all any CI system needs.
**The command does not expand globs — your shell does.** So quote nothing, and turn on `globstar` if you want `**` to cross directories:
The command does not expand globs — your shell does. So quote nothing, and
turn on globstar if you want ** to cross directories:
```bash shopt -s globstar # bash; zsh has ** already npx visimark check docs/**/*.md ```
shopt -s globstar # bash; zsh has ** already
npx visimark check docs/**/*.md
A quoted `"docs/**/*.md"` reaches the tool as a literal filename, and you get exit code `2` and `cannot read **/*.md`. If you would rather not think about shell options:
A quoted "docs/**/*.md" reaches the tool as a literal filename, and you get
exit code 2 and cannot read **/*.md. If you would rather not think about
shell options:
```bash find docs -name '*.md' -print0 | xargs -0 npx visimark check ```
find docs -name '*.md' -print0 | xargs -0 npx visimark check
### GitHub Actions, by hand
GitHub Actions, by hand
```yaml
name: docs
on: [push, pull_request]
jobs:
visimark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: check the documents
run: |
shopt -s globstar nullglob
npx --yes visimark@0.1.5 check docs/**/*.md
```name: docs
on: [push, pull_request]
jobs:
visimark:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: check the documents
run: |
shopt -s globstar nullglob
npx --yes visimark@0.1.5 check docs/**/*.md
### GitHub Actions, with the shipped action
GitHub Actions, with the shipped action
```yaml
- uses: michal-niedzwiedzki/visimark@v0.1.5
with:
files: "docs/**/*.md"
``` - uses: michal-niedzwiedzki/visimark@v0.1.5
with:
files: "docs/**/*.md"
Inputs: `files` (a glob, `**` matches across directories), `command` (`check` or `fmt`), `args` (for example `--fix-dates`), and `version`.
Inputs: files (a glob, ** matches across directories), command (check or
fmt), args (for example --fix-dates), and version.
There is nothing else to configure. There is no strictness dial to find. Pointing it at a glob is the whole setup.
There is nothing else to configure. There is no strictness dial to find. Pointing it at a glob is the whole setup.
### Pin the version
Pin the version
The action's `version` input defaults to the release that action ref ships. So pinning the action at `@v0.1.5` also pins the engine that does the work. Re-run an unchanged commit next month and you get the same verifier.
The action's version input defaults to the release that action ref ships. So
pinning the action at @v0.1.5 also pins the engine that does the work. Re-run
an unchanged commit next month and you get the same verifier.
Set `version: latest` if you would rather track releases as they land.
Set version: latest if you would rather track releases as they land.
### `check` in CI, `fmt` on your machine
check in CI, fmt on your machine
Run **`check`** in CI. It is read-only, so it is safe to point at anything, and a failure is a real answer: the document contradicts itself, and a person should look.
Run check in CI. It is read-only, so it is safe to point at anything, and
a failure is a real answer: the document contradicts itself, and a person should
look.
Do not run `fmt` in CI and commit the result. That would repair the drift automatically, and the whole value of this tool is that a human sees the drift and decides whether the input or the formula was wrong. `fmt` belongs on a developer's machine, or behind format-on-save in an editor (chapter 28).
Do not run fmt in CI and commit the result. That would repair the drift
automatically, and the whole value of this tool is that a human sees the drift
and decides whether the input or the formula was wrong. fmt belongs on a
developer's machine, or behind format-on-save in an editor (chapter 28).
The one reasonable exception is a job that runs `fmt` and then fails if the file changed — a "you forgot to run fmt" check. That still puts the decision on a person.
The one reasonable exception is a job that runs fmt and then fails if the file
changed — a "you forgot to run fmt" check. That still puts the decision on a
person.
### What to tell a contributor whose build failed
What to tell a contributor whose build failed
The report already says it, but this is the short version:
The report already says it, but this is the short version:
- **`STALE`** — run `visimark fmt FILE`, then look at the diff and decide if those new numbers are what you meant. - **`COVERAGE`** — run `visimark infer FILE` and read the proposal. - **Anything else** — a person has to answer it. The finding says which person question it is. Chapter 21 is the full list.
STALE— runvisimark fmt FILE, then look at the diff and decide if those new numbers are what you meant.COVERAGE— runvisimark infer FILEand read the proposal.- Anything else — a person has to answer it. The finding says which person question it is. Chapter 21 is the full list.
### Machine-readable output
Machine-readable output
Every command takes `--json`:
Every command takes --json:
```json
{
"command": "check",
"visimark": "0.1.5",
"status": "problems",
"files": [
{
"path": "undef.md",
"findings": [
{
"code": "UNDEF",
"class": "problem",
"location": { "file": "undef.md", "sheet": "s", "name": "Net" },
"details": { "suggestion": "Price" }
}
],
"summary": { "problems": 1, "stale": 0, "errors": 1 }
}
],
"summary": { "files": 1, "problems": 1, "stale": 0, "errors": 1 }
}
```{
"command": "check",
"visimark": "0.1.5",
"status": "problems",
"files": [
{
"path": "undef.md",
"findings": [
{
"code": "UNDEF",
"class": "problem",
"location": { "file": "undef.md", "sheet": "s", "name": "Net" },
"details": { "suggestion": "Price" }
}
],
"summary": { "problems": 1, "stale": 0, "errors": 1 }
}
],
"summary": { "files": 1, "problems": 1, "stale": 0, "errors": 1 }
}
This is what you use to turn findings into PR annotations.
This is what you use to turn findings into PR annotations.
An option a command does not accept is refused, not ignored: `--jsonn` exits `2` with a did-you-mean, and so does `--fix-dates` on `check`. Nothing is read or written first.
An option a command does not accept is refused, not ignored: --jsonn exits 2
with a did-you-mean, and so does --fix-dates on check. Nothing is read or
written first.
## 26. Knowledge extraction: a document a machine can read
26. Knowledge extraction: a document a machine can read
A VisiMark document is not only checkable. It is queryable. `visimark eval` prints the computed values, so a script can act on them.
A VisiMark document is not only checkable. It is queryable. visimark eval
prints the computed values, so a script can act on them.
### All the values
All the values
```console $ visimark eval docs/tutorial/order.md order.net_total 158 order.line_count 3 order.avg_line 52.67 tax.tax_total 36.34 tax.gross_total 194.34 order.Net 50, 60, 48 tax.Base 158 tax.Tax 36.34 ```
$ visimark eval docs/tutorial/order.md
order.net_total 158
order.line_count 3
order.avg_line 52.67
tax.tax_total 36.34
tax.gross_total 194.34
order.Net 50, 60, 48
tax.Base 158
tax.Tax 36.34
Both scalars and columns. Names are `sheet.name`.
Both scalars and columns. Names are sheet.name.
### One value, for a shell script
One value, for a shell script
```console $ visimark eval docs/tutorial/order.md --get tax.gross_total 194.34 ```
$ visimark eval docs/tutorial/order.md --get tax.gross_total
194.34
Nothing else on stdout. This is the form to pipe.
Nothing else on stdout. This is the form to pipe.
`--get` takes `sheet.name`, or a bare `name` when it is unambiguous. An unknown name exits `2` — "your request did not make sense" — which a script should treat differently from `1`.
--get takes sheet.name, or a bare name when it is unambiguous. An unknown
name exits 2 — "your request did not make sense" — which a script should treat
differently from 1.
### Structured output
Structured output
```json
{
"command": "eval",
"visimark": "0.1.5",
"status": "ok",
"file": "docs/tutorial/order.md",
"values": {
"order.net_total": "158",
"order.line_count": "3",
"order.avg_line": "52.67",
"tax.gross_total": "194.34",
"order.Net": ["50", "60", "48"]
},
"assertions": [
{
"sheet": "tax",
"source": "assert gross_total >= order.net_total",
"holds": true,
"operands": { "gross_total": "194.34", "order.net_total": "158.00" },
"substituted": "194.34 >= 158.00"
}
],
"charts": []
}
```{
"command": "eval",
"visimark": "0.1.5",
"status": "ok",
"file": "docs/tutorial/order.md",
"values": {
"order.net_total": "158",
"order.line_count": "3",
"order.avg_line": "52.67",
"tax.gross_total": "194.34",
"order.Net": ["50", "60", "48"]
},
"assertions": [
{
"sheet": "tax",
"source": "assert gross_total >= order.net_total",
"holds": true,
"operands": { "gross_total": "194.34", "order.net_total": "158.00" },
"substituted": "194.34 >= 158.00"
}
],
"charts": []
}
Three things to notice:
Three things to notice:
**Quantities are decimal strings, not JSON numbers.** `"158"`, not `158`. This is on purpose. JSON numbers are IEEE floats, and money is not. Parse them with a decimal library, or keep them as strings.
Quantities are decimal strings, not JSON numbers. "158", not 158. This
is on purpose. JSON numbers are IEEE floats, and money is not. Parse them with a
decimal library, or keep them as strings.
**A column is an array.** A scalar is a single string.
A column is an array. A scalar is a single string.
**Assertions come with the values filled in.** `substituted` is the assertion with each name replaced by what it evaluated to. A monitoring script can report *why* a claim failed, not just that it did.
Assertions come with the values filled in. substituted is the assertion
with each name replaced by what it evaluated to. A monitoring script can report
why a claim failed, not just that it did.
`eval` exits `1` when an assertion is false — and still prints the values first.
eval exits 1 when an assertion is false — and still prints the values first.
### Why this matters: one source of truth
Why this matters: one source of truth
Normally a number that both a person and a machine need lives twice: once in a document for the person, once in a config file for the machine. The two drift, and nobody notices until something breaks.
Normally a number that both a person and a machine need lives twice: once in a document for the person, once in a config file for the machine. The two drift, and nobody notices until something breaks.
`eval` removes the second copy. The document a person reads *is* the thing the machine reads. Three real shapes of this:
eval removes the second copy. The document a person reads is the thing the
machine reads. Three real shapes of this:
**A CI shard matrix.** The test-suite timings live in a table someone can read, with an assertion that no suite may cost more than double the average. A packaging script calls `eval --get suite.total_seconds` and builds the matrix. There is no YAML file to forget to update. See [`example-ci-sharding.md`](example-ci-sharding.md).
A CI shard matrix. The test-suite timings live in a table someone can read,
with an assertion that no suite may cost more than double the average. A
packaging script calls eval --get suite.total_seconds and builds the matrix.
There is no YAML file to forget to update. See
example-ci-sharding.md.
**An agent spending gate.** A run ledger has a budget scalar and one row per tool call, with `assert spent <= budget`. The harness calls `eval --get calls.spent` **before** dispatching each call. The agent doing the spending is not the actor doing the accounting. See [`example-agent-budget.md`](example-agent-budget.md).
An agent spending gate. A run ledger has a budget scalar and one row per
tool call, with assert spent <= budget. The harness calls
eval --get calls.spent before dispatching each call. The agent doing the
spending is not the actor doing the accounting. See
example-agent-budget.md.
**A capacity decision.** A budget document derives the maximum affordable number of Kubernetes worker nodes from the cost of everything else. The cluster size is not an independently maintained configuration value any more; it is a consequence of the budget, and it is re-derived every time the budget changes. See [`example-executable-documentation.md`](example-executable-documentation.md).
A capacity decision. A budget document derives the maximum affordable number
of Kubernetes worker nodes from the cost of everything else. The cluster size is
not an independently maintained configuration value any more; it is a
consequence of the budget, and it is re-derived every time the budget changes.
See example-executable-documentation.md.
### What a script should not depend on
What a script should not depend on
- **The human text output.** Its layout is for people. Use `--json`. - **Key order in the JSON.** - **Findings being absent.** Check the exit code.
- The human text output. Its layout is for people. Use
--json. - Key order in the JSON.
- Findings being absent. Check the exit code.
### The next question: what if?
The next question: what if?
`eval` answers *what does this document say?* The next question a script or an agent asks is *what would it say if one assumption changed?* That is also `eval`, with a scenario, and it is Part 8.
eval answers what does this document say? The next question a script or an
agent asks is what would it say if one assumption changed? That is also
eval, with a scenario, and it is Part 8.
## 27. Working with an AI agent
27. Working with an AI agent
This format was designed with agents in mind, and the reason is narrow and specific.
This format was designed with agents in mind, and the reason is narrow and specific.
An agent is **reliable at writing formulas**. `Net = Qty * Rate` is language, and language is what it is good at.
An agent is reliable at writing formulas. Net = Qty * Rate is language, and
language is what it is good at.
An agent is **unreliable at arithmetic**. `50.00` is a guess that looks like a fact, and it looks equally like a fact when it is wrong.
An agent is unreliable at arithmetic. 50.00 is a guess that looks like a
fact, and it looks equally like a fact when it is wrong.
Write the formula instead of the number, and the number stops being a claim. It becomes a derivation: reviewable in a diff, re-runnable, enforceable in CI. The agent writes, VisiMark verifies, Git records the result.
Write the formula instead of the number, and the number stops being a claim. It becomes a derivation: reviewable in a diff, re-runnable, enforceable in CI. The agent writes, VisiMark verifies, Git records the result.
### The skill file
The skill file
[`skills/visimark/SKILL.md`](../skills/visimark/SKILL.md) is an agent skill for authoring and verifying these documents. Copy it to `~/.claude/skills/visimark/` to install it.
skills/visimark/SKILL.md is an agent skill for
authoring and verifying these documents. Copy it to ~/.claude/skills/visimark/
to install it.
### The one rule to give an agent
The one rule to give an agent
**Never write a number you calculated yourself.** If a number follows from other numbers, it belongs in a `vmark` block as a rule, and the tool writes the value.
Never write a number you calculated yourself. If a number follows from other
numbers, it belongs in a vmark block as a rule, and the tool writes the value.
### The rationalizations to refuse
The rationalizations to refuse
These are the excuses that show up in practice — from agents and from people.
These are the excuses that show up in practice — from agents and from people.
| Excuse | Reality | |---|---| | "The arithmetic is trivial, I will just write 4800" | Trivial arithmetic is still wrong sometimes, and the value stops being reviewable. Write the rule. | | "`check` passed, so the document is correct" | `check` passes on a document with no formulas. Change an input and watch it break. | | "I will add the formulas after the prose reads well" | You will forget, and nothing will tell you. Table, block, anchors, then prose. | | "A totals row is more readable" | It breaks the rectangle. Totals are scalars reached through anchors. | | "I will just fix that one cell by hand" | That cell is an output. Change the input or the rule and run `fmt`. | | "The document already has numbers, I will write the same rules by hand" | Run `infer` first. Hand-authoring re-does its work and can introduce the exact mistake the tool exists to catch. | | "The date format is obvious from context" | `11/12/2026` is two different dates. | | "I will widen the anchor to get more decimals" | The anchor is an output. Change the binding's `precision N`. | | "I will change the rate, look at the total, and put it back" | That edit rewrites every figure below it and can be committed by accident. Declare a `param` and ask with `eval --scenario` (chapter 30). |
| Excuse | Reality |
|---|---|
| "The arithmetic is trivial, I will just write 4800" | Trivial arithmetic is still wrong sometimes, and the value stops being reviewable. Write the rule. |
"check passed, so the document is correct" |
check passes on a document with no formulas. Change an input and watch it break. |
| "I will add the formulas after the prose reads well" | You will forget, and nothing will tell you. Table, block, anchors, then prose. |
| "A totals row is more readable" | It breaks the rectangle. Totals are scalars reached through anchors. |
| "I will just fix that one cell by hand" | That cell is an output. Change the input or the rule and run fmt. |
| "The document already has numbers, I will write the same rules by hand" | Run infer first. Hand-authoring re-does its work and can introduce the exact mistake the tool exists to catch. |
| "The date format is obvious from context" | 11/12/2026 is two different dates. |
| "I will widen the anchor to get more decimals" | The anchor is an output. Change the binding's precision N. |
| "I will change the rate, look at the total, and put it back" | That edit rewrites every figure below it and can be committed by accident. Declare a param and ask with eval --scenario (chapter 30). |
### The review loop
The review loop
1. The agent writes the table, the block, the anchors and the prose — in that order. 2. It runs `visimark fmt` to fill in every computed value. 3. It changes one input, confirms `check` starts failing, and puts the input back. (Chapter 5.) 4. You review the diff: inputs, rules and assertions. Not the arithmetic. 5. CI runs `check` on every commit afterwards.
- The agent writes the table, the block, the anchors and the prose — in that order.
- It runs
visimark fmtto fill in every computed value. - It changes one input, confirms
checkstarts failing, and puts the input back. (Chapter 5.) - You review the diff: inputs, rules and assertions. Not the arithmetic.
- CI runs
checkon every commit afterwards.
Step 4 is the change worth having. Reviewing an agent's arithmetic is slow and unreliable. Reviewing an agent's *inputs and rules* is fast, and it is a question you can actually answer.
Step 4 is the change worth having. Reviewing an agent's arithmetic is slow and unreliable. Reviewing an agent's inputs and rules is fast, and it is a question you can actually answer.
## 28. In the editor
28. In the editor
The CLI is the product, and everything works without an editor. But there is a language server wrapping the same engine, and a VS Code client for it.
The CLI is the product, and everything works without an editor. But there is a language server wrapping the same engine, and a VS Code client for it.
What you get:
What you get:
- **Live diagnostics** — the findings from chapter 21, as you type. - **`fmt` behind format-on-save** — using the editor's own setting, so it behaves like every other formatter you have. - **Quick fixes** — for the findings that have one. - **Inlay hints** — the computed value shown next to a formula, without touching the bytes of your file. - **CodeLens and hover.**
- Live diagnostics — the findings from chapter 21, as you type.
fmtbehind format-on-save — using the editor's own setting, so it behaves like every other formatter you have.- Quick fixes — for the findings that have one.
- Inlay hints — the computed value shown next to a formula, without touching the bytes of your file.
- CodeLens and hover.
At the time of writing, the extension is not on a marketplace. Build it from a clone:
At the time of writing, the extension is not on a marketplace. Build it from a clone:
```console $ bun run vscode-install # build, package and install $ bun run vscode-uninstall # remove it ```
$ bun run vscode-install # build, package and install
$ bun run vscode-uninstall # remove it
Reload the window afterwards. Both targets need the `code` CLI on your PATH.
Reload the window afterwards. Both targets need the code CLI on your PATH.
The design is written up in [`visimark-editor-plugins-design.md`](visimark-editor-plugins-design.md).
The design is written up in
visimark-editor-plugins-design.md.
There is also a browser playground — [`playground.html`](playground.html) — that runs the real engine on a document you edit in the page, with a live preview and a knowledge panel. It is the fastest way to try something without installing anything.
There is also a browser playground — playground.html — that
runs the real engine on a document you edit in the page, with a live preview and
a knowledge panel. It is the fastest way to try something without installing
anything.
---
# Part 8 — Modelling
Part 8 — Modelling
A document computes one answer from one set of numbers. People keep asking the second question: *what would this come to if…?* What if we hire four people instead of two? What if the customer pays no VAT? What if the raise is 5.5%?
A document computes one answer from one set of numbers. People keep asking the second question: what would this come to if…? What if we hire four people instead of two? What if the customer pays no VAT? What if the raise is 5.5%?
Without help, there is one way to ask: edit the number, run `fmt`, read the result, and put the edit back. That edit is the problem. It rewrites every figure below it, and it leaves a diff for a question nobody meant to commit. Forget to put it back, and the document now says something nobody decided.
Without help, there is one way to ask: edit the number, run fmt, read the
result, and put the edit back. That edit is the problem. It rewrites every
figure below it, and it leaves a diff for a question nobody meant to commit.
Forget to put it back, and the document now says something nobody decided.
VisiMark splits this in two. The document says **which** numbers are assumptions (chapter 29). `eval` answers the what-if **without writing anything** (chapter 30).
VisiMark splits this in two. The document says which numbers are
assumptions (chapter 29). eval answers the what-if without writing
anything (chapter 30).
## 29. Parameters: the assumptions a reader may vary
29. Parameters: the assumptions a reader may vary
The example for this part is [`tutorial/runway.md`](tutorial/runway.md), a small plan for how long a company's cash lasts:
The example for this part is tutorial/runway.md, a
small plan for how long a company's cash lasts:
````markdown | Team | Heads | Salary | Cost | |-------------|------:|---------:|---------:| | Engineering | 6 | 11000.00 | 67980.00 | | Sales | 3 | 9000.00 | 27810.00 | | Operations | 2 | 7500.00 | 15450.00 | ```vmark #team param raise precision 3 = default 3% Cost = ROUND(Heads * Salary * (1 + raise), 2) headcount = SUM(Heads) payroll = SUM(Cost) ``` The team of **11**<!--vmark=team.headcount--> people costs **111240.00**<!--vmark=team.payroll--> PLN a month, after a **3.0%**<!--vmark=team.raise%--> raise. ```vmark #runway param cash precision 2 = default 2000000.00 param overhead precision 2 = default 21000.00 param new_hires precision 0 = default 2 param hire_cost precision 2 = default 10500.00 burn = team.payroll + overhead + new_hires * hire_cost months precision 1 = cash / burn assert months >= 12 ``` With **2**<!--vmark=runway.new_hires--> new hires the company spends **153240.00**<!--vmark=runway.burn--> PLN a month, so the cash lasts **13.1**<!--vmark=runway.months--> months. The plan requires at least twelve. ````
| Team | Heads | Salary | Cost |
|-------------|------:|---------:|---------:|
| Engineering | 6 | 11000.00 | 67980.00 |
| Sales | 3 | 9000.00 | 27810.00 |
| Operations | 2 | 7500.00 | 15450.00 |
```vmark #team
param raise precision 3 = default 3%
Cost = ROUND(Heads * Salary * (1 + raise), 2)
headcount = SUM(Heads)
payroll = SUM(Cost)
```
The team of **11**<!--vmark=team.headcount--> people costs
**111240.00**<!--vmark=team.payroll--> PLN a month, after a
**3.0%**<!--vmark=team.raise%--> raise.
```vmark #runway
param cash precision 2 = default 2000000.00
param overhead precision 2 = default 21000.00
param new_hires precision 0 = default 2
param hire_cost precision 2 = default 10500.00
burn = team.payroll + overhead + new_hires * hire_cost
months precision 1 = cash / burn
assert months >= 12
```
With **2**<!--vmark=runway.new_hires--> new hires the company spends
**153240.00**<!--vmark=runway.burn--> PLN a month, so the cash lasts
**13.1**<!--vmark=runway.months--> months. The plan requires at least twelve.
### The shape of a `param`
The shape of a param
``` param raise precision 3 = default 3% ```
param raise precision 3 = default 3%
Read it aloud: *a parameter, `raise`, three decimals wide, which is 3% unless a scenario says otherwise.* Every part is required, in this order:
Read it aloud: a parameter, raise, three decimals wide, which is 3% unless a
scenario says otherwise. Every part is required, in this order:
- **`param`** — this value may be supplied from outside. - **the name** — a plain identifier. A `param` is a scalar, never a column. - **`precision N`** — required, even where a plain scalar would derive its width. A value that arrives from outside has no width the document could work out. The width is also the limit on what a scenario may bring (chapter 30). - **`default` and a number** — the value the document uses. It must be a plain number, such as `2`, `10500.00` or `3%`. It cannot be a formula, a name, a date or a string. A value you compute is an ordinary binding, not a `param`.
param— this value may be supplied from outside.- the name — a plain identifier. A
paramis a scalar, never a column. precision N— required, even where a plain scalar would derive its width. A value that arrives from outside has no width the document could work out. The width is also the limit on what a scenario may bring (chapter 30).defaultand a number — the value the document uses. It must be a plain number, such as2,10500.00or3%. It cannot be a formula, a name, a date or a string. A value you compute is an ordinary binding, not aparam.
### The default is the document
The default is the document
Everywhere except one place, `param raise precision 3 = default 3%` behaves exactly like `raise precision 3 = 3%`. `check`, `fmt`, `infer`, `explain` and a plain `eval` all use the default. So every stored cell, every anchor and every chart in the file shows the default's numbers, and that is what `check` verifies and what a reviewer reads.
Everywhere except one place, param raise precision 3 = default 3% behaves
exactly like raise precision 3 = 3%. check, fmt, infer, explain and a
plain eval all use the default. So every stored cell, every anchor and every
chart in the file shows the default's numbers, and that is what check
verifies and what a reviewer reads.
This is the one rule the whole part follows from: **the defaults are the document. A scenario is only a view of it.**
This is the one rule the whole part follows from: the defaults are the document. A scenario is only a view of it.
A `param` behaves like any other scalar. Formulas read it, locally or as `team.raise`. An assertion can read it. An anchor can show it, and the anchor always shows the default. Above, `**3.0%**<!--vmark=team.raise%-->` prints the default raise as a percent (chapter 15). It shows one decimal because `raise` is three decimals wide.
A param behaves like any other scalar. Formulas read it, locally or as
team.raise. An assertion can read it. An anchor can show it, and the anchor
always shows the default. Above, **3.0%**<!--vmark=team.raise%--> prints the
default raise as a percent (chapter 15). It shows one decimal because raise
is three decimals wide.
### Choosing the width
Choosing the width
The width is a promise about what a scenario may bring. `raise` is declared three decimals wide, so a scenario may ask about `5.5%` (`0.055`) but not `5.25%` (`0.0525`). Declare the width that covers every question you expect to ask. It is also the width the default is written at: the default must fit, so `param raise precision 1 = default 3%` is an error, because `3%` is `0.03`.
The width is a promise about what a scenario may bring. raise is declared
three decimals wide, so a scenario may ask about 5.5% (0.055) but not
5.25% (0.0525). Declare the width that covers every question you expect to
ask. It is also the width the default is written at: the default must fit, so
param raise precision 1 = default 3% is an error, because 3% is 0.03.
### Domains: which values are even askable
Domains: which values are even askable
`precision 3` only bounds the *width* `raise` is written at. Nothing stops a scenario asking for `raise = -50%` or `raise = 999%` — only an `assert` downstream would catch either, and only after the whole document has already been evaluated with that value. A `param` can also declare its **domain**: the set of values a scenario is even allowed to try.
precision 3 only bounds the width raise is written at. Nothing stops a
scenario asking for raise = -50% or raise = 999% — only an assert
downstream would catch either, and only after the whole document has already
been evaluated with that value. A param can also declare its domain: the
set of values a scenario is even allowed to try.
The example for this is [`tutorial/levers.md`](tutorial/levers.md):
The example for this is tutorial/levers.md:
````markdown
```vmark #levers
param extra_hours precision 0 integer in [0, 80] = default 40
param prepay_share precision 2 in { 30%, 40%, 45%, 50% } = default 30%
max_extra_hours = 80
max_prepay = 40%
assert levers.extra_hours <= max_extra_hours
assert levers.prepay_share <= max_prepay
```
```````vmark #levers
param extra_hours precision 0 integer in [0, 80] = default 40
param prepay_share precision 2 in { 30%, 40%, 45%, 50% } = default 30%
max_extra_hours = 80
max_prepay = 40%
assert levers.extra_hours <= max_extra_hours
assert levers.prepay_share <= max_prepay
```
A domain clause is optional, and sits between `precision N` and `= default`:
A domain clause is optional, and sits between precision N and = default:
``` param NAME precision N [PRESET] [in DOMAIN-EXPR] = default LITERAL ```
param NAME precision N [PRESET] [in DOMAIN-EXPR] = default LITERAL
- **`extra_hours`** declares `integer in [0, 80]`: a *preset* (`integer` — no
fractions) narrowed by a *range* (`in [0, 80]`, both ends closed). Only the
81 whole numbers from 0 to 80 are legal scenario values.
- **`prepay_share`** declares `in { 30%, 40%, 45%, 50% }`: a *finite set*.
There is no such thing as a 35% share of this business, so 35% is not a
legal question to ask.extra_hoursdeclaresinteger in [0, 80]: a preset (integer— no fractions) narrowed by a range (in [0, 80], both ends closed). Only the 81 whole numbers from 0 to 80 are legal scenario values.prepay_sharedeclaresin { 30%, 40%, 45%, 50% }: a finite set. There is no such thing as a 35% share of this business, so 35% is not a legal question to ask.
Read a range the way a maths textbook would: `[0, 80]` includes both ends, `(0, 80)` excludes both, and `[0, 80)` or `(0, 80]` excludes exactly one — the two ends are independently open or closed. Either end may be left out to mean unbounded that way: `[0, )` means "0 or more."
Read a range the way a maths textbook would: [0, 80] includes both ends,
(0, 80) excludes both, and [0, 80) or (0, 80] excludes exactly one — the
two ends are independently open or closed. Either end may be left out to mean
unbounded that way: [0, ) means "0 or more."
Four named presets cover the common cases, keyword or glyph:
Four named presets cover the common cases, keyword or glyph:
| Preset | Keyword | Glyph | Legal values | |---|---|---|---| | the integers, any sign | `integer` | `ℤ` | `…, -1, 0, 1, …` | | a positive number, any width | `positive` | *(none)* | `x > 0` | | the naturals | `natural` | `ℕ` | integers, `x ≥ 0` — **includes 0** | | the positive integers | `positive integer` | `ℤ⁺` | integers, `x > 0` — **excludes 0** |
| Preset | Keyword | Glyph | Legal values |
|---|---|---|---|
| the integers, any sign | integer |
ℤ |
…, -1, 0, 1, … |
| a positive number, any width | positive |
(none) | x > 0 |
| the naturals | natural |
ℕ |
integers, x ≥ 0 — includes 0 |
| the positive integers | positive integer |
ℤ⁺ |
integers, x > 0 — excludes 0 |
`natural` and `positive integer` differ only at the boundary: a headcount that may drop to zero is `natural`; one that must keep at least one seat is `positive integer`. A preset alone needs no range:
natural and positive integer differ only at the boundary: a headcount that
may drop to zero is natural; one that must keep at least one seat is
positive integer. A preset alone needs no range:
``` param staff_added precision 0 positive integer = default 1 ```
param staff_added precision 0 positive integer = default 1
A preset and a range narrow to their intersection, and a range end need not be closed on both sides:
A preset and a range narrow to their intersection, and a range end need not be closed on both sides:
``` param crosssell_days precision 0 ℕ in [0, 8) = default 0 ```
param crosssell_days precision 0 ℕ in [0, 8) = default 0
reads as *the naturals, 0 up to but not including 8* — a half-open range on a `natural` preset, written with the glyph. `∈` is accepted in place of `in` everywhere a domain clause appears, the same way `ℕ`/`ℤ`/`ℤ⁺` are accepted in place of the keywords. `fmt` never rewrites one spelling to the other, the same rule chapter 12's `Σ`/`SUM` and `√`/`SQRT` already follow.
reads as the naturals, 0 up to but not including 8 — a half-open range on a
natural preset, written with the glyph. ∈ is accepted in place of in
everywhere a domain clause appears, the same way ℕ/ℤ/ℤ⁺ are accepted in
place of the keywords. fmt never rewrites one spelling to the other, the
same rule chapter 12's Σ/SUM and √/SQRT already follow.
A `param` with neither a preset nor an `in` clause is exactly today's `param`, unaffected by anything in this section.
A param with neither a preset nor an in clause is exactly today's param,
unaffected by anything in this section.
### What a reader sees
What a reader sees
`explain` lists the params apart from the other scalars, with their widths and defaults as written:
explain lists the params apart from the other scalars, with their widths and
defaults as written:
```console
$ visimark explain runway.md
#team
inputs: Team, Heads, Salary
rules:
Cost = ROUND(Heads * Salary * (1 + raise), 2) precision 2 (derived)
scalars:
headcount = SUM(Heads) precision 0 (derived)
payroll = SUM(Cost) precision 2 (derived)
params:
raise precision 3 default 3%
order: raise → Cost → headcount → payroll
#runway (no table)
scalars:
burn = team.payroll + overhead + new_hires * hire_cost precision 2 (derived)
months = cash / burn precision 1 (declared)
params:
cash precision 2 default 2000000.00
overhead precision 2 default 21000.00
new_hires precision 0 default 2
hire_cost precision 2 default 10500.00
order: cash → overhead → new_hires → hire_cost → burn → months
assertions:
months >= 12
```$ visimark explain runway.md
#team
inputs: Team, Heads, Salary
rules:
Cost = ROUND(Heads * Salary * (1 + raise), 2) precision 2 (derived)
scalars:
headcount = SUM(Heads) precision 0 (derived)
payroll = SUM(Cost) precision 2 (derived)
params:
raise precision 3 default 3%
order: raise → Cost → headcount → payroll
#runway (no table)
scalars:
burn = team.payroll + overhead + new_hires * hire_cost precision 2 (derived)
months = cash / burn precision 1 (declared)
params:
cash precision 2 default 2000000.00
overhead precision 2 default 21000.00
new_hires precision 0 default 2
hire_cost precision 2 default 10500.00
order: cash → overhead → new_hires → hire_cost → burn → months
assertions:
months >= 12
That `params:` list is the document's answer to *what here is an assumption?* It is worth reading in every review. Everything else in the document follows from the inputs, the params and the rules.
That params: list is the document's answer to what here is an assumption?
It is worth reading in every review. Everything else in the document follows
from the inputs, the params and the rules.
A param with a domain shows it right there, alongside its width and default:
A param with a domain shows it right there, alongside its width and default:
```console
$ visimark explain levers.md
#levers (no table)
scalars:
max_extra_hours = 80 precision 0 (derived)
max_prepay = 40% precision 1 (derived)
params:
extra_hours precision 0 default 40 domain integer in [0, 80]
prepay_share precision 2 default 30% domain { 30%, 40%, 45%, 50% }
order: extra_hours → prepay_share → max_extra_hours → max_prepay
assertions:
levers.extra_hours <= max_extra_hours
levers.prepay_share <= max_prepay
```$ visimark explain levers.md
#levers (no table)
scalars:
max_extra_hours = 80 precision 0 (derived)
max_prepay = 40% precision 1 (derived)
params:
extra_hours precision 0 default 40 domain integer in [0, 80]
prepay_share precision 2 default 30% domain { 30%, 40%, 45%, 50% }
order: extra_hours → prepay_share → max_extra_hours → max_prepay
assertions:
levers.extra_hours <= max_extra_hours
levers.prepay_share <= max_prepay
### Mistakes, and what they report
Mistakes, and what they report
```console
PRECISION s.raise param raise declares no width
write `param raise precision N = default …`
``` PRECISION s.raise param raise declares no width
write `param raise precision N = default …`
```console PRECISION s.raise default 3% has 2 decimals; param raise declares 1 ```
PRECISION s.raise default 3% has 2 decimals; param raise declares 1
```console TYPE s.raise expected `default` after `=` in a param ```
TYPE s.raise expected `default` after `=` in a param
```console TYPE s.raise a param default must be a number literal ```
TYPE s.raise a param default must be a number literal
A default outside its own domain is a `DOMAIN` error, checked before anything is evaluated:
A default outside its own domain is a DOMAIN error, checked before anything
is evaluated:
```console DOMAIN levers.extra_hours default 100 is not in the domain of extra_hours: integer in [0, 80] ```
DOMAIN levers.extra_hours default 100 is not in the domain of extra_hours: integer in [0, 80]
A `param` with the same name as a column of its sheet is a `DUP` error, and one nothing reads gets the usual `WARN`.
A param with the same name as a column of its sheet is a DUP error, and one
nothing reads gets the usual WARN.
### `param` is not a reserved word
param is not a reserved word
`param` and `default` are only keywords in exactly this statement. `param = 5` still binds a scalar called `param`, and `default = 3` still binds one called `default`. No older document changes meaning.
param and default are only keywords in exactly this statement. param = 5
still binds a scalar called param, and default = 3 still binds one called
default. No older document changes meaning.
### What should be a `param`?
What should be a param?
A number someone will ask a *what if* about: a rate, a price, a headcount, a budget, a growth assumption. Not every constant. A `param` announces "this may vary", so an unmarked constant tells the reader something too: this one is settled.
A number someone will ask a what if about: a rate, a price, a headcount, a
budget, a growth assumption. Not every constant. A param announces "this may
vary", so an unmarked constant tells the reader something too: this one is
settled.
A number that differs from row to row stays an input column. A `param` is one number for the whole sheet.
A number that differs from row to row stays an input column. A param is one
number for the whole sheet.
## 30. Scenarios: asking what-if without editing
30. Scenarios: asking what-if without editing
A **scenario** is a small JSON file that gives some params other values:
A scenario is a small JSON file that gives some params other values:
```console
$ cat four-hires.json
{ "new_hires": "4" }
```$ cat four-hires.json
{ "new_hires": "4" }
Pass it to `eval`:
Pass it to eval:
```console
$ visimark eval --scenario four-hires.json runway.md
team.raise 0.03
team.headcount 11
team.payroll 111240
runway.cash 2000000
runway.overhead 21000
runway.new_hires 4
runway.hire_cost 10500
runway.burn 174240
runway.months 11.5
team.Cost 67980, 27810, 15450
scenario: four-hires.json
team.raise 0.03 default
runway.cash 2000000 default
runway.overhead 21000 default
runway.new_hires 4 scenario (default 2)
runway.hire_cost 10500 default
ASSERT #runway months >= 12
11.5 >= 12 is false under scenario (holds on defaults)
$ echo $?
1
```$ visimark eval --scenario four-hires.json runway.md
team.raise 0.03
team.headcount 11
team.payroll 111240
runway.cash 2000000
runway.overhead 21000
runway.new_hires 4
runway.hire_cost 10500
runway.burn 174240
runway.months 11.5
team.Cost 67980, 27810, 15450
scenario: four-hires.json
team.raise 0.03 default
runway.cash 2000000 default
runway.overhead 21000 default
runway.new_hires 4 scenario (default 2)
runway.hire_cost 10500 default
ASSERT #runway months >= 12
11.5 >= 12 is false under scenario (holds on defaults)
$ echo $?
1
Read it from the top.
Read it from the top.
- **The values** are computed with four hires. The whole document is evaluated again, in the same order, with the same rounding and the same functions. Only the params differ. - **The `scenario:` block** lists every param, where its value came from, and the default it replaced. The answer always says which question it answers. - **The assertion** is checked under the scenario. With four hires the cash lasts 11.5 months, and the plan says twelve. - **`(holds on defaults)`** tells you the assertion is true for the document as written. It is this scenario that breaks it. The other endings are `(also false on defaults)` — the document was already broken — and `(unverified on defaults)`. - **Exit code `1`**, because an assertion is false. The values are still printed first.
- The values are computed with four hires. The whole document is evaluated again, in the same order, with the same rounding and the same functions. Only the params differ.
- The
scenario:block lists every param, where its value came from, and the default it replaced. The answer always says which question it answers. - The assertion is checked under the scenario. With four hires the cash lasts 11.5 months, and the plan says twelve.
(holds on defaults)tells you the assertion is true for the document as written. It is this scenario that breaks it. The other endings are(also false on defaults)— the document was already broken — and(unverified on defaults).- Exit code
1, because an assertion is false. The values are still printed first.
That is what happens when a scenario value is legal but the plan does not survive it. A value outside a param's declared domain (chapter 29) never gets that far — it is refused before the document is evaluated at all, and nothing is printed:
That is what happens when a scenario value is legal but the plan does not survive it. A value outside a param's declared domain (chapter 29) never gets that far — it is refused before the document is evaluated at all, and nothing is printed:
```console
$ cat too-many-hours.json
{ "extra_hours": "100" }
$ visimark eval --scenario too-many-hours.json levers.md
visimark: scenario value for extra_hours is not in [0, 80]: 100
$ echo $?
2
```$ cat too-many-hours.json
{ "extra_hours": "100" }
$ visimark eval --scenario too-many-hours.json levers.md
visimark: scenario value for extra_hours is not in [0, 80]: 100
$ echo $?
2
Exit `2`, the usage-error code (chapter 3) — the same code a misspelled key or a wrong-width value already gets. No `scenario:` block, no values, because nothing was ever evaluated. `100` is a legal *width* for `extra_hours` (`precision 0`), which is exactly why the domain check exists: the width alone would have let this scenario run.
Exit 2, the usage-error code (chapter 3) — the same code a misspelled key or
a wrong-width value already gets. No scenario: block, no values, because
nothing was ever evaluated. 100 is a legal width for extra_hours
(precision 0), which is exactly why the domain check exists: the width alone
would have let this scenario run.
Back to `runway.md`. Now look at the file:
Back to runway.md. Now look at the file:
```console $ git status --short runway.md $ ```
$ git status --short runway.md
$
Nothing changed. `eval` never writes, with or without a scenario.
Nothing changed. eval never writes, with or without a scenario.
### Change several things at once
Change several things at once
```console
$ cat leaner.json
{ "raise": "5.5%", "hire_cost": "9800.00" }
$ visimark eval --scenario leaner.json runway.md
team.raise 0.055
team.headcount 11
team.payroll 113940
runway.cash 2000000
runway.overhead 21000
runway.new_hires 2
runway.hire_cost 9800
runway.burn 154540
runway.months 12.9
team.Cost 69630, 28485, 15825
scenario: leaner.json
team.raise 0.055 scenario (default 0.03)
runway.cash 2000000 default
runway.overhead 21000 default
runway.new_hires 2 default
runway.hire_cost 9800 scenario (default 10500)
$ echo $?
0
```$ cat leaner.json
{ "raise": "5.5%", "hire_cost": "9800.00" }
$ visimark eval --scenario leaner.json runway.md
team.raise 0.055
team.headcount 11
team.payroll 113940
runway.cash 2000000
runway.overhead 21000
runway.new_hires 2
runway.hire_cost 9800
runway.burn 154540
runway.months 12.9
team.Cost 69630, 28485, 15825
scenario: leaner.json
team.raise 0.055 scenario (default 0.03)
runway.cash 2000000 default
runway.overhead 21000 default
runway.new_hires 2 default
runway.hire_cost 9800 scenario (default 10500)
$ echo $?
0
A param the scenario does not mention keeps its default. An empty object, `{}`,
is a valid scenario and gives the same values as a plain `eval`.A param the scenario does not mention keeps its default. An empty object, {},
is a valid scenario and gives the same values as a plain eval.
Notice that `team.Cost` changed too. A `param` feeds column rules like any other scalar, so every row moves.
Notice that team.Cost changed too. A param feeds column rules like any
other scalar, so every row moves.
`eval` prints values in their shortest exact form: `2000000`, not `2000000.00`, and `0.03`, not `3%`. That is the same as a plain `eval` (chapter 26).
eval prints values in their shortest exact form: 2000000, not
2000000.00, and 0.03, not 3%. That is the same as a plain eval
(chapter 26).
### The rules for a scenario file
The rules for a scenario file
**Keys** name declared params: `sheet.name`, or just `name` when only one param in the document has that name.
Keys name declared params: sheet.name, or just name when only one param
in the document has that name.
**Values are strings.** `"4"`, not `4`. A JSON number passes through a binary float in almost every program that writes JSON, and money does not survive that. A string keeps the exact decimal text.
Values are strings. "4", not 4. A JSON number passes through a binary
float in almost every program that writes JSON, and money does not survive
that. A string keeps the exact decimal text.
**A percent param takes a percent.** `raise` has a percent default, so the value must be `"5.5%"`. A bare `"5.5"` would mean 550%, and it is refused instead of believed.
A percent param takes a percent. raise has a percent default, so the
value must be "5.5%". A bare "5.5" would mean 550%, and it is refused
instead of believed.
**A value must fit the declared width.** It is never rounded to fit.
A value must fit the declared width. It is never rounded to fit.
**A value must fit the declared domain too**, if the param has one (chapter 29) — the `extra_hours` example above. A value that fits the width but not the domain is refused the same way, before evaluation.
A value must fit the declared domain too, if the param has one (chapter
29) — the extra_hours example above. A value that fits the width but not the
domain is refused the same way, before evaluation.
Every mistake stops the run with exit `2` before anything is evaluated, and says what is wrong:
Every mistake stops the run with exit 2 before anything is evaluated, and
says what is wrong:
```console visimark: scenario key new_hire names no param; did you mean new_hires? visimark: scenario value for new_hires must be a string: write "4" visimark: raise is a percent; write "5.5%" visimark: scenario value for raise has 4 decimals; raise declares 3 visimark: scenario key burn is a rule, not a param visimark: scenario key Salary is a column, not a param visimark: scenario value for cash is not a number: "2,000,000.00" ```
visimark: scenario key new_hire names no param; did you mean new_hires?
visimark: scenario value for new_hires must be a string: write "4"
visimark: raise is a percent; write "5.5%"
visimark: scenario value for raise has 4 decimals; raise declares 3
visimark: scenario key burn is a rule, not a param
visimark: scenario key Salary is a column, not a param
visimark: scenario value for cash is not a number: "2,000,000.00"
This strictness is the point. A misspelled key that was quietly ignored would give you an answer that looks right and ignores your question.
This strictness is the point. A misspelled key that was quietly ignored would give you an answer that looks right and ignores your question.
### Only `eval` accepts a scenario
Only eval accepts a scenario
```console $ visimark check --scenario four-hires.json runway.md visimark: --scenario is only valid with eval $ echo $? 2 ```
$ visimark check --scenario four-hires.json runway.md
visimark: --scenario is only valid with eval
$ echo $?
2
`check`, `fmt`, `infer`, `explain` and `ref` refuse `--scenario`. A `fmt` that quietly ignored it would let you think a scenario had been written into the document. So `check` always answers about the document as written, and two people who run it on the same commit always get the same answer.
check, fmt, infer, explain and ref refuse --scenario. A fmt that
quietly ignored it would let you think a scenario had been written into the
document. So check always answers about the document as written, and two
people who run it on the same commit always get the same answer.
### One value, and scenarios from a program
One value, and scenarios from a program
`--get` works with a scenario and prints just the one value:
--get works with a scenario and prints just the one value:
```console $ visimark eval --scenario four-hires.json runway.md --get runway.months 11.5 ```
$ visimark eval --scenario four-hires.json runway.md --get runway.months
11.5
The failed assertion is still reported, but on stderr, so stdout holds only the number. The exit code is still `1`.
The failed assertion is still reported, but on stderr, so stdout holds only the
number. The exit code is still 1.
`--scenario -` reads the scenario from stdin, so a script needs no temporary files. This loop asks the same question for zero to five hires:
--scenario - reads the scenario from stdin, so a script needs no temporary
files. This loop asks the same question for zero to five hires:
```console
$ for n in 0 1 2 3 4 5; do
> printf '%s hires: ' $n
> echo "{ \"new_hires\": \"$n\" }" |
> visimark eval --scenario - runway.md --get runway.months 2>/dev/null
> done
0 hires: 15.1
1 hires: 14
2 hires: 13.1
3 hires: 12.2
4 hires: 11.5
5 hires: 10.8
```$ for n in 0 1 2 3 4 5; do
> printf '%s hires: ' $n
> echo "{ \"new_hires\": \"$n\" }" |
> visimark eval --scenario - runway.md --get runway.months 2>/dev/null
> done
0 hires: 15.1
1 hires: 14
2 hires: 13.1
3 hires: 12.2
4 hires: 11.5
5 hires: 10.8
The plan holds up to three hires. That table was produced by the document's own rules, not by a copy of them in a spreadsheet.
The plan holds up to three hires. That table was produced by the document's own rules, not by a copy of them in a spreadsheet.
### For a program: `--json`
For a program: --json
Under `--json` the scenario comes back with the values, and a failed assertion carries a `defaults` field with `pass`, `fail` or `unverified`:
Under --json the scenario comes back with the values, and a failed assertion
carries a defaults field with pass, fail or unverified:
```json
{
"command": "eval",
"visimark": "0.1.6",
"status": "problems",
"file": "runway.md",
"scenario": {
"file": "four-hires.json",
"params": {
"team.raise": { "value": "0.03", "default": "0.03", "source": "default" },
"runway.cash": { "value": "2000000", "default": "2000000", "source": "default" },
"runway.overhead": { "value": "21000", "default": "21000", "source": "default" },
"runway.new_hires": { "value": "4", "default": "2", "source": "scenario" },
"runway.hire_cost": { "value": "10500", "default": "10500", "source": "default" }
}
},
"values": {
"runway.burn": "174240",
"runway.months": "11.5",
…
},
"assertions": [
{
"sheet": "runway",
"source": "assert months >= 12",
"holds": false,
"operands": { "months": "11.5" },
"substituted": "11.5 >= 12",
"defaults": "pass"
}
],
"charts": []
}
```{
"command": "eval",
"visimark": "0.1.6",
"status": "problems",
"file": "runway.md",
"scenario": {
"file": "four-hires.json",
"params": {
"team.raise": { "value": "0.03", "default": "0.03", "source": "default" },
"runway.cash": { "value": "2000000", "default": "2000000", "source": "default" },
"runway.overhead": { "value": "21000", "default": "21000", "source": "default" },
"runway.new_hires": { "value": "4", "default": "2", "source": "scenario" },
"runway.hire_cost": { "value": "10500", "default": "10500", "source": "default" }
}
},
"values": {
"runway.burn": "174240",
"runway.months": "11.5",
…
},
"assertions": [
{
"sheet": "runway",
"source": "assert months >= 12",
"holds": false,
"operands": { "months": "11.5" },
"substituted": "11.5 >= 12",
"defaults": "pass"
}
],
"charts": []
}
The JSON above is shortened and re-indented. `"defaults": "pass"` is the machine form of `(holds on defaults)`.
The JSON above is shortened and re-indented. "defaults": "pass" is the
machine form of (holds on defaults).
### Why this is safe to hand to an agent
Why this is safe to hand to an agent
An agent that plans work can ask the document before it acts: *would this plan still fit the budget?* It runs a scenario, reads the exit code and the `defaults` field, and decides. It never needs write access to the document, and it cannot change the answer by editing a number, because the document it asks is the one a person reviewed. [`example-agent-budget.md`](example-agent-budget.md) does exactly this with a spending cap.
An agent that plans work can ask the document before it acts: would this plan
still fit the budget? It runs a scenario, reads the exit code and the
defaults field, and decides. It never needs write access to the document, and
it cannot change the answer by editing a number, because the document it asks
is the one a person reviewed.
example-agent-budget.md does exactly this with a
spending cap.
### What a scenario is not
What a scenario is not
- **Not a way to change the document.** To adopt a scenario's values, edit the defaults, run `fmt`, and commit that diff for review like any other change. - **Not a stored variant.** VisiMark does not keep a list of named scenarios in the document. The scenario files are yours: keep them next to the document, or build them in a script. - **Not a way to vary a formula.** Only `param` values vary. The rules are the same in every scenario.
- Not a way to change the document. To adopt a scenario's values, edit
the defaults, run
fmt, and commit that diff for review like any other change. - Not a stored variant. VisiMark does not keep a list of named scenarios in the document. The scenario files are yours: keep them next to the document, or build them in a script.
- Not a way to vary a formula. Only
paramvalues vary. The rules are the same in every scenario.
---
# Part 9 — Putting it together
Part 9 — Putting it together
## 31. Capstone: a quote, end to end
31. Capstone: a quote, end to end
Now build a real document from nothing, using everything. The finished file is [`tutorial/capstone.md`](tutorial/capstone.md). It is a consulting quote with line items, VAT, a payment schedule derived from the total, a reconciliation that proves the instalments add up, and a VAT rate you can ask what-if questions about.
Now build a real document from nothing, using everything. The finished file is
tutorial/capstone.md. It is a consulting quote with
line items, VAT, a payment schedule derived from the total, a reconciliation
that proves the instalments add up, and a VAT rate you can ask what-if
questions about.
Follow along. Every command is shown.
Follow along. Every command is shown.
### Step 1 — the table, inputs only
Step 1 — the table, inputs only
The header is written for the reader, not for the tool. Do not rename it.
The header is written for the reader, not for the tool. Do not rename it.
```markdown | Stage | Effort (man-days) | Rate | Net | VAT | Gross | |------------------|---------------------:|--------:|------:|------:|------:| | Discovery | 6 | 900.00 | 0.00 | 0.00 | 0.00 | | Schema mapping | 14 | 850.00 | 0.00 | 0.00 | 0.00 | | Migration runs | 9 | 850.00 | 0.00 | 0.00 | 0.00 | | Cutover support | 4 | 1100.00 | 0.00 | 0.00 | 0.00 | ```
| Stage | Effort (man-days) | Rate | Net | VAT | Gross |
|------------------|---------------------:|--------:|------:|------:|------:|
| Discovery | 6 | 900.00 | 0.00 | 0.00 | 0.00 |
| Schema mapping | 14 | 850.00 | 0.00 | 0.00 | 0.00 |
| Migration runs | 9 | 850.00 | 0.00 | 0.00 | 0.00 |
| Cutover support | 4 | 1100.00 | 0.00 | 0.00 | 0.00 |
Three inputs, three placeholders. Leave room in the computed columns so the table still lines up after `fmt`.
Three inputs, three placeholders. Leave room in the computed columns so the
table still lines up after fmt.
### Step 2 — the block
Step 2 — the block
````markdown ```vmark #lines "Effort (man-days)" is days param vat_rate precision 2 = default 23% Net = days * Rate VAT = ROUND(Net * vat_rate, 2) Gross = Net + VAT effort_total = SUM(days) net_total = SUM(Net) vat_total = SUM(VAT) gross_total = SUM(Gross) day_rate_avg precision 2 = net_total / effort_total ``` ````
```vmark #lines
"Effort (man-days)" is days
param vat_rate precision 2 = default 23%
Net = days * Rate
VAT = ROUND(Net * vat_rate, 2)
Gross = Net + VAT
effort_total = SUM(days)
net_total = SUM(Net)
vat_total = SUM(VAT)
gross_total = SUM(Gross)
day_rate_avg precision 2 = net_total / effort_total
```
Five decisions are visible here, and each one is a chapter you have read:
Five decisions are visible here, and each one is a chapter you have read:
- The alias (chapter 18) lets the header stay as written. - The VAT rate is a `param` (chapter 29). It is 23% in the document, and a what-if run may try another rate. Its width, 2, allows any whole percent. - `ROUND(…, 2)` on VAT (chapter 14) keeps money at two decimals, as a stated decision rather than an accident of widths. - `day_rate_avg` divides, so it declares its width (chapter 14). - Every total is a scalar, not a row (chapter 8).
- The alias (chapter 18) lets the header stay as written.
- The VAT rate is a
param(chapter 29). It is 23% in the document, and a what-if run may try another rate. Its width, 2, allows any whole percent. ROUND(…, 2)on VAT (chapter 14) keeps money at two decimals, as a stated decision rather than an accident of widths.day_rate_avgdivides, so it declares its width (chapter 14).- Every total is a scalar, not a row (chapter 8).
### Step 3 — the prose, with anchors
Step 3 — the prose, with anchors
```markdown The engagement is **0**<!--vmark=lines.effort_total--> man-days at an average of **0.00**<!--vmark=lines.day_rate_avg--> PLN per day. Net of tax it comes to **0.00**<!--vmark=lines.net_total--> PLN. VAT at **0**<!--vmark=lines.vat_rate%--> adds **0.00**<!--vmark=lines.vat_total--> PLN, giving a total of **0.00**<!--vmark=lines.gross_total--> PLN gross. ```
The engagement is **0**<!--vmark=lines.effort_total--> man-days at an
average of **0.00**<!--vmark=lines.day_rate_avg--> PLN per day. Net of tax it
comes to **0.00**<!--vmark=lines.net_total--> PLN. VAT at
**0**<!--vmark=lines.vat_rate%--> adds **0.00**<!--vmark=lines.vat_total-->
PLN, giving a total of **0.00**<!--vmark=lines.gross_total--> PLN gross.
Currency stays outside the anchors (chapter 9). The rate is not typed into the sentence: the `%` anchor prints the `param` as a percent (chapter 15), so the sentence cannot disagree with the rate the formulas use.
Currency stays outside the anchors (chapter 9). The rate is not typed into the
sentence: the % anchor prints the param as a percent (chapter 15), so the
sentence cannot disagree with the rate the formulas use.
### Step 4 — the schedule, a second sheet
Step 4 — the schedule, a second sheet
````markdown | Milestone | Share | Amount | Due | |------------------|------:|-------:|------------| | Signature | 25% | 0.00 | 2026-10-01 | | Schema sign-off | 45% | 0.00 | 2026-11-16 | | Cutover accepted | 30% | 0.00 | 2027-01-15 | ```vmark #schedule Amount = ROUND(Share * lines.gross_total, 2) covered = SUM(Amount) share_sum precision 2 = SUM(Share) assert share_sum == 1 ``` ````
| Milestone | Share | Amount | Due |
|------------------|------:|-------:|------------|
| Signature | 25% | 0.00 | 2026-10-01 |
| Schema sign-off | 45% | 0.00 | 2026-11-16 |
| Cutover accepted | 30% | 0.00 | 2027-01-15 |
```vmark #schedule
Amount = ROUND(Share * lines.gross_total, 2)
covered = SUM(Amount)
share_sum precision 2 = SUM(Share)
assert share_sum == 1
```
`lines.gross_total` crosses sheets and is qualified (chapter 10). Dates are ISO (chapter 17). The assertion says the shares must be a whole (chapter 19).
lines.gross_total crosses sheets and is qualified (chapter 10). Dates are ISO
(chapter 17). The assertion says the shares must be a whole (chapter 19).
### Step 5 — the reconciliation, a sheet with no table
Step 5 — the reconciliation, a sheet with no table
````markdown ```vmark #recon invoiced = lines.gross_total scheduled = schedule.covered variance = scheduled - invoiced assert |variance| <= 0.05 ``` ````
```vmark #recon
invoiced = lines.gross_total
scheduled = schedule.covered
variance = scheduled - invoiced
assert |variance| <= 0.05
```
Rounding each instalment can leave a few grosz of remainder. The tolerance is written down where a reviewer can argue with it, instead of being assumed. `|variance|` is the absolute value, written the way a reader expects (chapter 12).
Rounding each instalment can leave a few grosz of remainder. The tolerance is
written down where a reviewer can argue with it, instead of being assumed.
|variance| is the absolute value, written the way a reader expects
(chapter 12).
### Step 6 — fill it in
Step 6 — fill it in
```console $ visimark fmt capstone.md capstone.md: updated 15 cells, 10 anchors $ visimark check capstone.md capstone.md 0 problems (0 stale, 0 errors) ```
$ visimark fmt capstone.md
capstone.md: updated 15 cells, 10 anchors
$ visimark check capstone.md
capstone.md
0 problems (0 stale, 0 errors)
### Step 7 — prove it (do not skip this)
Step 7 — prove it (do not skip this)
Change one input. Raise the schema-mapping effort from 14 days to 16:
Change one input. Raise the schema-mapping effort from 14 days to 16:
```console $ visimark check capstone.md capstone.md STALE lines.Net · Schema mapping 11900.00 ≠ 13600.00 days * Rate STALE lines.VAT · Schema mapping 2737.00 ≠ 3128.00 ROUND(Net * vat_rate, 2) STALE lines.Gross · Schema mapping 14637.00 ≠ 16728.00 Net + VAT STALE lines.effort_total 33 ≠ 35 SUM(days) STALE lines.net_total 29350.00 ≠ 31050.00 SUM(Net) STALE lines.vat_total 6750.50 ≠ 7141.50 SUM(VAT) STALE lines.gross_total 36100.50 ≠ 38191.50 SUM(Gross) STALE lines.day_rate_avg 889.39 ≠ 887.14 STALE schedule.Amount · Signature 9025.13 ≠ 9547.88 ROUND(Share * lines.gross_total, 2) STALE schedule.Amount · Schema sign-off 16245.23 ≠ 17186.18 ROUND(Share * lines.gross_total, 2) STALE schedule.Amount · Cutover accepted 10830.15 ≠ 11457.45 ROUND(Share * lines.gross_total, 2) STALE schedule.covered 36100.51 ≠ 38191.51 SUM(Amount) STALE recon.invoiced 36100.50 ≠ 38191.50 STALE recon.scheduled 36100.51 ≠ 38191.51 STALE 8 prose anchors bound to the values above 22 problems (22 stale, 0 errors) ```
$ visimark check capstone.md
capstone.md
STALE lines.Net · Schema mapping 11900.00 ≠ 13600.00 days * Rate
STALE lines.VAT · Schema mapping 2737.00 ≠ 3128.00 ROUND(Net * vat_rate, 2)
STALE lines.Gross · Schema mapping 14637.00 ≠ 16728.00 Net + VAT
STALE lines.effort_total 33 ≠ 35 SUM(days)
STALE lines.net_total 29350.00 ≠ 31050.00 SUM(Net)
STALE lines.vat_total 6750.50 ≠ 7141.50 SUM(VAT)
STALE lines.gross_total 36100.50 ≠ 38191.50 SUM(Gross)
STALE lines.day_rate_avg 889.39 ≠ 887.14
STALE schedule.Amount · Signature 9025.13 ≠ 9547.88 ROUND(Share * lines.gross_total, 2)
STALE schedule.Amount · Schema sign-off 16245.23 ≠ 17186.18 ROUND(Share * lines.gross_total, 2)
STALE schedule.Amount · Cutover accepted 10830.15 ≠ 11457.45 ROUND(Share * lines.gross_total, 2)
STALE schedule.covered 36100.51 ≠ 38191.51 SUM(Amount)
STALE recon.invoiced 36100.50 ≠ 38191.50
STALE recon.scheduled 36100.51 ≠ 38191.51
STALE 8 prose anchors bound to the values above
22 problems (22 stale, 0 errors)
Twenty-two numbers depend on that one input. In a plain Markdown quote, all twenty-two would still say the old value, and the document would render perfectly.
Twenty-two numbers depend on that one input. In a plain Markdown quote, all twenty-two would still say the old value, and the document would render perfectly.
Run `visimark fmt` and all twenty-two move together, in one small diff.
Run visimark fmt and all twenty-two move together, in one small diff.
### Step 8 — watch an assertion earn its keep
Step 8 — watch an assertion earn its keep
Now break something an arithmetic check alone would not catch. Change the last milestone's share from 30% to 35%:
Now break something an arithmetic check alone would not catch. Change the last milestone's share from 30% to 35%:
```console
$ visimark check capstone.md
capstone.md
STALE schedule.Amount · Cutover accepted 10830.15 ≠ 12635.18 ROUND(Share * lines.gross_total, 2)
STALE schedule.covered 36100.51 ≠ 37905.54 SUM(Amount)
STALE recon.scheduled 36100.51 ≠ 37905.54
STALE recon.variance 0.01 ≠ 1805.04
STALE 3 prose anchors bound to the values above
ASSERT #schedule share_sum == 1
1.05 == 1 is false
ASSERT #recon |variance| <= 0.05
|1805.04| <= 0.05 is false
9 problems (7 stale, 2 errors)
```$ visimark check capstone.md
capstone.md
STALE schedule.Amount · Cutover accepted 10830.15 ≠ 12635.18 ROUND(Share * lines.gross_total, 2)
STALE schedule.covered 36100.51 ≠ 37905.54 SUM(Amount)
STALE recon.scheduled 36100.51 ≠ 37905.54
STALE recon.variance 0.01 ≠ 1805.04
STALE 3 prose anchors bound to the values above
ASSERT #schedule share_sum == 1
1.05 == 1 is false
ASSERT #recon |variance| <= 0.05
|1805.04| <= 0.05 is false
9 problems (7 stale, 2 errors)
This is the important difference. `fmt` would happily repair all seven `STALE` findings, and the document would then be internally consistent — and still wrong, because the shares add to 105% and the schedule over-collects by 1805.04 PLN.
This is the important difference. fmt would happily repair all seven STALE
findings, and the document would then be internally consistent — and still
wrong, because the shares add to 105% and the schedule over-collects by 1805.04
PLN.
The two assertions survive `fmt` and keep failing. They are the part of the document that says what *should* be true, rather than what is.
The two assertions survive fmt and keep failing. They are the part of the
document that says what should be true, rather than what is.
### Step 9 — put it in CI
Step 9 — put it in CI
```yaml
- uses: michal-niedzwiedzki/visimark@v0.1.5
with:
files: "docs/**/*.md"
```- uses: michal-niedzwiedzki/visimark@v0.1.5
with:
files: "docs/**/*.md"
### Step 10 — read a number back out
Step 10 — read a number back out
```console $ visimark eval capstone.md --get lines.gross_total 36100.5 ```
$ visimark eval capstone.md --get lines.gross_total
36100.5
`eval` prints the shortest exact form, `36100.5`. The document shows `36100.50` because the binding is two decimals wide. They are the same number.
eval prints the shortest exact form, 36100.5. The document shows
36100.50 because the binding is two decimals wide. They are the same number.
### Step 11 — ask a what-if
Step 11 — ask a what-if
The customer is a company in Prague. If the sale falls under the EU reverse charge, the quote carries no Polish VAT. What would the quote and the schedule come to then? Do not edit the rate. Ask:
The customer is a company in Prague. If the sale falls under the EU reverse charge, the quote carries no Polish VAT. What would the quote and the schedule come to then? Do not edit the rate. Ask:
```console
$ cat reverse-charge.json
{ "vat_rate": "0%" }
$ visimark eval --scenario reverse-charge.json capstone.md
lines.vat_rate 0
lines.effort_total 33
lines.net_total 29350
lines.vat_total 0
lines.gross_total 29350
lines.day_rate_avg 889.39
schedule.covered 29350
schedule.share_sum 1
recon.invoiced 29350
recon.scheduled 29350
recon.variance 0
lines.Net 5400, 11900, 7650, 4400
lines.VAT 0, 0, 0, 0
lines.Gross 5400, 11900, 7650, 4400
schedule.Amount 7337.5, 13207.5, 8805
scenario: reverse-charge.json
lines.vat_rate 0 scenario (default 0.23)
```$ cat reverse-charge.json
{ "vat_rate": "0%" }
$ visimark eval --scenario reverse-charge.json capstone.md
lines.vat_rate 0
lines.effort_total 33
lines.net_total 29350
lines.vat_total 0
lines.gross_total 29350
lines.day_rate_avg 889.39
schedule.covered 29350
schedule.share_sum 1
recon.invoiced 29350
recon.scheduled 29350
recon.variance 0
lines.Net 5400, 11900, 7650, 4400
lines.VAT 0, 0, 0, 0
lines.Gross 5400, 11900, 7650, 4400
schedule.Amount 7337.5, 13207.5, 8805
scenario: reverse-charge.json
lines.vat_rate 0 scenario (default 0.23)
The whole chain follows the one changed assumption: VAT, gross, every instalment and the reconciliation. Both assertions still hold, so the exit code is `0`. And `capstone.md` has not changed by a single byte.
The whole chain follows the one changed assumption: VAT, gross, every
instalment and the reconciliation. Both assertions still hold, so the exit
code is 0. And capstone.md has not changed by a single byte.
You are done. That document now computes itself, states its own invariants, fails a build when it drifts, answers a script, and answers a what-if without being edited.
You are done. That document now computes itself, states its own invariants, fails a build when it drifts, answers a script, and answers a what-if without being edited.
## 32. What VisiMark refuses to do
32. What VisiMark refuses to do
Knowing the limits saves you from fighting them.
Knowing the limits saves you from fighting them.
**Where a value could mean two things, it errors rather than guesses.** Dates are ISO only. Thousands separators are refused. A column mixing `$` and `€` is an error, not a sum. A name bound twice is an error, not an overwrite.
Where a value could mean two things, it errors rather than guesses. Dates are
ISO only. Thousands separators are refused. A column mixing $ and € is an
error, not a sum. A name bound twice is an error, not an overwrite.
**There are no booleans in cells.** A stored value is a number, a date or a string.
There are no booleans in cells. A stored value is a number, a date or a string.
**There is no plugin architecture, and there will not be one.** A document's numbers depend on its own text and the version of VisiMark reading it, and on nothing else: no extension modules, no config file, no environment, no network, no clock.
There is no plugin architecture, and there will not be one. A document's numbers depend on its own text and the version of VisiMark reading it, and on nothing else: no extension modules, no config file, no environment, no network, no clock.
This is the most important refusal. Host-supplied functions would produce documents whose arithmetic cannot be checked from the document — which is the one thing this format exists to prevent. When the builtin vocabulary is too small, the answer is a new primitive in the engine, readable and runnable by everyone. When a value genuinely comes from outside, it belongs in an input column where a human wrote it down.
This is the most important refusal. Host-supplied functions would produce documents whose arithmetic cannot be checked from the document — which is the one thing this format exists to prevent. When the builtin vocabulary is too small, the answer is a new primitive in the engine, readable and runnable by everyone. When a value genuinely comes from outside, it belongs in an input column where a human wrote it down.
This makes the format **smaller**, not only stricter. There is no locale, no configuration, and no rule for what a bare `/` means in a date.
This makes the format smaller, not only stricter. There is no locale, no
configuration, and no rule for what a bare / means in a date.
**It is not a spreadsheet replacement.** No grid, no cell styling, no presentation layer, no Excel compatibility, no attempt at Excel's function library. Use a spreadsheet when what you want is a spreadsheet.
It is not a spreadsheet replacement. No grid, no cell styling, no presentation layer, no Excel compatibility, no attempt at Excel's function library. Use a spreadsheet when what you want is a spreadsheet.
Use VisiMark when what you want is a **document**: plain text, readable without the tool, reviewable in an ordinary pull request, writable by a human or an agent — with numbers that can be checked on every commit.
Use VisiMark when what you want is a document: plain text, readable without the tool, reviewable in an ordinary pull request, writable by a human or an agent — with numbers that can be checked on every commit.
### Asking for something new
Asking for something new
Requests to grow the vocabulary, and proposals for any other language or tooling change, go through [`vocabulary-catalogue.md`](vocabulary-catalogue.md), which records every one and the decision on it. The review process is [`issue-runbook.md`](issue-runbook.md).
Requests to grow the vocabulary, and proposals for any other language or tooling
change, go through
vocabulary-catalogue.md, which records every one and
the decision on it. The review process is
issue-runbook.md.
## 33. Where to go next
33. Where to go next
### Reference
Reference
| Document | What it answers | |---|---| | [`ci.md`](ci.md) | Protect your Markdown numbers with CI — the Action, globs, annotations, pinning and rollout | | [`mcp-server.md`](mcp-server.md) | Set up and run `visimark-mcp` — a host, the write gate, the plan/apply split | | [`cli-reference.md`](cli-reference.md) | Every command, option, exit code and finding, in tables | | [`function-reference.md`](function-reference.md) | What each of the sixteen builtins does, with examples that run in CI | | [`visimark-design.md`](visimark-design.md) | The normative specification, the deferred work, and the known tensions |
| Document | What it answers |
|---|---|
ci.md |
Protect your Markdown numbers with CI — the Action, globs, annotations, pinning and rollout |
mcp-server.md |
Set up and run visimark-mcp — a host, the write gate, the plan/apply split |
cli-reference.md |
Every command, option, exit code and finding, in tables |
function-reference.md |
What each of the sixteen builtins does, with examples that run in CI |
visimark-design.md |
The normative specification, the deferred work, and the known tensions |
Or ask the tool: `visimark ref NAME`.
Or ask the tool: visimark ref NAME.
### Worked examples to read
Worked examples to read
| Document | Why read it | |---|---| | [`example-invoice.md`](example-invoice.md) | A complete self-computing B2B invoice. Its appendix explains each mechanism. | | [`example-invoice-drift.md`](example-invoice-drift.md) | The same invoice after one input changed and nothing else. 26 findings, each walked through. | | [`example-quote-plain.md`](example-quote-plain.md) | A quote with no VisiMark in it at all — the `infer` path, start to finish. | | [`example-charts.md`](example-charts.md) | Generated chart artifacts. | | [`example-structural-check.md`](example-structural-check.md) | An engineering calculation, and a transposed digit caught before the framer sees it. | | [`example-ci-sharding.md`](example-ci-sharding.md) | A document a build tool reads. | | [`example-agent-budget.md`](example-agent-budget.md) | A spending cap an agent cannot talk itself out of, and a what-if run against it. | | [`example-executable-documentation.md`](example-executable-documentation.md) | A capacity decision that stopped being a second source of truth, with ratios printed as percents. |
| Document | Why read it |
|---|---|
example-invoice.md |
A complete self-computing B2B invoice. Its appendix explains each mechanism. |
example-invoice-drift.md |
The same invoice after one input changed and nothing else. 26 findings, each walked through. |
example-quote-plain.md |
A quote with no VisiMark in it at all — the infer path, start to finish. |
example-charts.md |
Generated chart artifacts. |
example-structural-check.md |
An engineering calculation, and a transposed digit caught before the framer sees it. |
example-ci-sharding.md |
A document a build tool reads. |
example-agent-budget.md |
A spending cap an agent cannot talk itself out of, and a what-if run against it. |
example-executable-documentation.md |
A capacity decision that stopped being a second source of truth, with ratios printed as percents. |
### Try it without installing
Try it without installing
[`playground.html`](playground.html) runs the real engine in your browser, with a live preview and a knowledge panel.
playground.html runs the real engine in your browser, with
a live preview and a knowledge panel.
### The six things worth remembering
The six things worth remembering
1. **Never write a number that another number implies.** Write the rule. 2. **A green check proves agreement, not derivation.** Change an input and watch it break. 3. **Never hand-edit an output.** Change the input or the rule, then `fmt`. 4. **`fmt` repairs stale values and nothing else.** Every other finding is a question for a person. 5. **Look functions up.** `visimark ref NAME`. Do not guess. 6. **Ask what-if with a scenario, not an edit.** Declare the assumption a `param`, and run `eval --scenario`.
- Never write a number that another number implies. Write the rule.
- A green check proves agreement, not derivation. Change an input and watch it break.
- Never hand-edit an output. Change the input or the rule, then
fmt. fmtrepairs stale values and nothing else. Every other finding is a question for a person.- Look functions up.
visimark ref NAME. Do not guess. - Ask what-if with a scenario, not an edit. Declare the assumption a
param, and runeval --scenario.
<!--vmark:no-formulas-->