Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ jobs:
- run: ruff format --check plainsong tests
continue-on-error: true

# Every other job runs with the repository on sys.path, which is structurally
# blind to packaging. That is not hypothetical: the spec files once lived
# outside the package, so `plainsong spec` reported "no specs found" to
# everybody who installed rather than cloned -- through a release, with a
# fully green suite. This job builds a wheel, installs it into a clean venv
# and drives the console script from outside the checkout.
packaging:
name: the built wheel actually works
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install build
- run: python tools/verify_release.py --stage wheel

corpus:
name: notation library still parses
runs-on: ubuntu-latest
Expand Down
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,67 @@

Notable changes, newest first. Dates are ISO 8601.

## Unreleased

### `plainsong mcp` is deprecated

`plainsong-mcp` 1.0.0 is on PyPI, so for the first time there is somewhere to
send people. `plainsong mcp` still works and will keep working until 2.0; it now
warns and names the replacement.

That duplication has already cost twice. A DNS-rebinding fix existed in this
copy and not the sibling for months -- in the copy people `pip install` for MCP.
Then the same eight lines got the same two things wrong in both, because the
second was a copy of the first. Neither repository could notice either time.

The notice goes to **stderr**, and that is not a detail. In stdio mode stdout
*is* the protocol: a deprecation line printed there would desynchronise every
client, turning a courtesy into an outage. `PLAINSONG_NO_DEPRECATION=1` silences
it. A test drives the real subprocess and parses every stdout line as JSON, so
the rule is enforced rather than remembered.


### `plainsong spec` called finding nothing a pass

It printed `no specs found` and exited **0**. That is the exact shape of the
fault it exists to catch: the spec files once sat in a top-level `specs/`
directory, outside the package, so every `pip install` shipped without them --
and every install, and every CI job that ran `plainsong spec`, read that zero
as a pass. The self-verification the whole design leans on was doing nothing,
loudly enough to print a warning and quietly enough that nobody's exit status
moved.

It exits 1 now, and says which of the two things happened: an install missing
its spec files, or a `--tag` nothing carries. This is a **behaviour change** --
a script that ran `plainsong spec` against an install with no specs and treated
0 as success will now see a failure, which is the point.

Found while building `tools/verify_release.py` (below): a wheel built with the
specs deliberately excluded still passed the packaging canary, because the
canary trusted the exit status. Verified directly -- that wheel carries zero
`spec_files` entries, and `plainsong spec` in a venv installed from it reports
`no specs found`.

### A release is now verified from outside the tree

Everything in `tests/` runs with the repository on `sys.path`, which is
structurally blind to packaging: the "no specs found" bug above lived through a
release with a fully green suite. `tools/verify_release.py` never imports
plainsong. It builds a wheel, installs it into a throwaway virtualenv outside
the source tree, and drives the console script from `/tmp` with `PYTHONPATH`
stripped, then repeats against what is actually on PyPI -- including driving
the MCP server with real JSON-RPC over stdio and checking that the sibling's
loopback re-export is the compiler's own function.

CI gains a `packaging` job running `--stage wheel`, so a data file that stops
being packaged fails a pull request rather than a release.

One trap it had to learn: setuptools copies the package into `build/lib` and
**reuses whatever is already there**, so a data file that has stopped being
packaged still reaches the wheel from the last build that did include it. A
broken package then verifies perfectly. The script clears `build/` and
`*.egg-info` before building for that reason.

## 1.4.0 — 2026-08-18

A minor rather than a patch: `plainsong.runtime.localhost` is a new public
Expand Down
48 changes: 41 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,21 @@ python3 -m unittest tests.test_notation.TestArrange.test_tokens_divide_the_bar
python3 -m pytest tests -q # works too, if pytest is installed

# The system's checks on itself -- run these before and after any change
python3 -m plainsong spec # exits non-zero on failure
python3 -m plainsong spec # exits non-zero on failure,
# and on finding no specs at all
python3 -m plainsong doctor --specs
python3 -m plainsong check docs examples plainsong/songbook README.md # every source, prose included
python3 -m plainsong fingerprint plainsong/songbook examples docs --check tests/corpus-fingerprint.txt

# Checks CI cannot run -- no browser there. Run by hand after touching either side.
python3 tools/demo_differential.py # the browser demo against the compiler

# The one check the test suite structurally cannot do: everything in tests/ runs
# with this repository on sys.path, so it never meets the artifact anyone
# installs. This builds a wheel, installs it outside the tree, and drives it.
python3 tools/verify_release.py --stage wheel # what CI gates on
python3 tools/verify_release.py # tree, wheel and PyPI

# Working with notation
python3 -m plainsong new "Title" -o song.song
python3 -m plainsong compile song.song -o out.mid --audio out.wav
Expand All @@ -69,7 +76,11 @@ python3 -m plainsong setup # connect a model
python3 -m plainsong build # tailor this install to the machine
```

Every command takes `--json`. Use it when parsing output.
Every command takes `--json`, but it is a **global** flag and goes before the
subcommand: `plainsong --json info song.song`. Written after it, argparse
refuses the whole invocation with `unrecognized arguments: --json`. Use it when
parsing output, and note the totals live under `arrangement` -- `arrangement.notes`
is the note count, not a top-level key.

Run the suite with `discover`, not by naming files. Several tests are about how
modules behave when imported in a particular order, and a single-file run can
Expand Down Expand Up @@ -203,6 +214,17 @@ there rather than by reasoning:
`chmod` is a no-op on Windows, so permission tests must be skipped there rather
than asserted around.

## How this project decides something is true

`docs/verification.md` is the short version and it is worth reading before you
trust any green result here. The one-line summary: **success is not evidence**.
`plainsong spec` once printed "no specs found" and exited 0, so every install
missing its spec files reported a pass; a guard that no mutation can fail is
decoration; and a test suite with this repository on `sys.path` cannot see a
packaging bug, which is why `tools/verify_release.py` never imports plainsong.

When you add a check, break the thing it checks and confirm it goes red.

## Specs

`plainsong/spec_files/*.toml` state what the system promises;
Expand Down Expand Up @@ -255,10 +277,13 @@ re-parse to fetch diagnostics you have already computed.

Related, and the reason that matters: an unrecognised token silently became a
rest. `Xm9` compiled "ok, 0 warnings" and produced a bar of nothing. It now
warns. Turning that on immediately found that `EbMaj7`, `G7alt` and `CM7` are
legitimate spellings the chord parser does not accept and has been quietly
dropping — still open, and it wants a spec and a changelog entry because it
changes how existing notation compiles.
warns. Turning that on immediately found that `EbMaj7`, `G7alt` and `CM7` were
legitimate spellings the chord parser did not accept and had been quietly
dropping. **That is fixed** -- all three parse, and `chordsymbol.parse_symbol`
is the place to confirm it rather than this paragraph. The lesson the entry is
kept for is the one that generalises: the warning is what found them. Before
it, an unreadable chord and a deliberate rest were the same silence, and the
compiler reported `ok` for both.

## Changing the notation

Expand Down Expand Up @@ -289,7 +314,10 @@ It lives inside the package because `plainsong library` and
`plainsong play stand-by-me` found nothing for anyone who had not cloned.

Two side effects worth knowing: the ~3,800 bar-count warnings this directory was
famous for came from those rows and are now 2.
famous for came from those rows and are now 1 -- the Hungarian Rhapsody's
`time: 2/4 (Lassan) then 4/4 (Friska)`, a human annotation the metre field
cannot express. That file is deliberately left alone, because changing the
metre would change the music.

## Rough edges

Expand All @@ -298,6 +326,12 @@ famous for came from those rows and are now 2.
- The host bridge cannot stream and reports no token usage.
- **`plainsong/mcp/` also exists in `SuperInstance/plainsong-mcp`.** The one
open violation of "one of everything". Do not build anything new on this copy.
**`plainsong mcp` is now deprecated and goes in 2.0.** It warns on stderr and
points at `pip install plainsong-mcp`, which is published and is the same
server maintained in one place. The notice cannot go on stdout -- that is the
protocol in stdio mode, and one stray line desynchronises every client, which
`tests/test_mcp.py::TestDeprecationNoticeStaysOffTheWire` holds by driving a
real subprocess and parsing every stdout line as JSON.

**This has now cost something real, so it is no longer a theoretical rule.**
The two copies were measured: 240 lines of difference across seven of eight
Expand Down
21 changes: 21 additions & 0 deletions coordinate/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2026 SuperInstance

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
82 changes: 82 additions & 0 deletions coordinate/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# coordinate

**Scheduling backwards from when an effect should land.**

One file, standard library only, MIT. Copy it next to whatever needs it.

Most scheduling says when to *act*. This says when the effect should *arrive*,
and solves backwards for when each participant has to move:

```
act = intent·scale + shift − alignment·(actuation + lead + reference_delay + bias)
effect = act + actuation + lead + bias + observed_delay
```

## Staged for extraction

This directory lives inside `SuperInstance/plainsong` for now because that is
where the idea was built and tested, and where the proof that the extraction is
inert can actually run. **It is destined for its own repository** and is not
part of the `plainsong` wheel — nothing under `plainsong/` imports it yet.

## Why the two delay terms must not be collapsed

`reference_delay` is the transport delay the plan was **compensated for**.
`observed_delay` is the delay **actually experienced** by whoever is watching
now.

When they are equal the correction cancels exactly and the effect lands where
it was written. When they differ it does not, and the residue is real. It is
why `spread` — the gap between the earliest and latest effect — is zero at the
point you tuned for and non-zero everywhere else, and why a coordinated group
needs a conductor rather than mutual listening.

Collapse those two into one variable and the model becomes symmetric,
self-consistent, and a description of nothing.

## `intent` survives compensation; `lead` does not

Swing is meant to be heard. A deliberate lead into a turn is meant to happen.
So `intent` moves the effect and is *not* solved away, while `lead` moves only
the action and *is* compensated. They look identical in the arithmetic and are
opposites in meaning, which is exactly why they are separate fields.

## The same three quantities, three domains

| | Orchestra | Boat helm | Camera / avatar cue |
|---|---|---|---|
| `actuation` | the instrument speaking | valve lag + hydraulic slew | rig acceleration, render lead |
| `bias` | habitual drag | linkage backlash | fixed pipeline stage |
| `reference_delay` | distance to the podium | conditions the autopilot was tuned in | the timing the sequence was cut against |
| `observed_delay` | distance to this listener | loaded, in current, in a seaway | this machine, this frame rate |
| `alignment` | ensemble discipline | trust in calibration right now | degraded mode |
| `intent` | swing, rubato | deliberate lead into a turn | an intentionally late reveal |
| `spread` | smear of a chord | **how far from tuning conditions you are** | cue drift across channels |

That middle column is the one worth dwelling on. An autopilot tuned in flat
water at one speed is running with a `reference_delay` that no longer matches
its `observed_delay` once the boat is loaded or in a seaway. `spread` turns
that mismatch into a number you can put on a screen.

## Proving a change is safe

`test_equivalence.py` drives this and `plainsong.perform.solve` with the same
inputs and requires **bit-identical** results across ~4,000 combinations —
`assertEqual`, not `assertAlmostEqual`, because a reordered sum is a different
implementation even when it is close, and close is what accumulates.

That is not a formality. Reordering one addition in `solve_one` — same terms,
same value mathematically — fails 1,803 of those cases.

```bash
cd coordinate && python3 test_equivalence.py
```

## What is deliberately not here

- **No I/O, no configuration, no logging.** It computes offsets in seconds.
- **No medium baked in.** `delay_for(distance, speed)` defaults to sound in air
and takes any speed. Where there is no distance at all, set the delays
directly and never call it.
- **No opinion about what a participant is.** A player, a steering pump, a
camera rig, an agent waiting on a message.
Loading
Loading