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
4 changes: 3 additions & 1 deletion .beads/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -61,5 +61,7 @@ import.auto: false
# - github.repo
types.custom: molecule,convoy,message,event,gate,merge-request,agent,role,rig,session,spec,convergence,step
dolt:
disable-event-flush: true
disable-event-flush: true
backup.enabled: false
sync:
remote: "git+https://github.com/hexsprite/intervaltree.git"
44 changes: 44 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [master, main]
pull_request:
branches: [master, main]
types: [opened, synchronize, reopened, labeled, unlabeled]
schedule:
# Run full model check once a day at 2 AM UTC
- cron: '0 2 * * *'
Expand All @@ -23,6 +24,7 @@ jobs:

- run: pnpm install
- run: pnpm build
- run: pnpm test:automation
- run: pnpm test
- run: pnpm lint
- run: pnpm typecheck
Expand Down Expand Up @@ -75,3 +77,45 @@ jobs:

- name: Test dist on Node ${{ matrix.node-version }}
run: pnpm test:compat

focuster-freelist:
# Only maintainer-owned release branches may access the private consumer.
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' &&
github.event.pull_request.head.repo.full_name == github.repository &&
startsWith(github.event.pull_request.head.ref, 'release-please--branches--master'))
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
path: intervaltree
persist-credentials: false
- uses: pnpm/action-setup@v4
with:
package_json_file: intervaltree/package.json
- uses: actions/setup-node@v4
with:
node-version: 24
- name: Pack the release candidate
working-directory: intervaltree
run: |
pnpm install --frozen-lockfile
mkdir -p "$RUNNER_TEMP/intervaltree-candidate"
pnpm pack --pack-destination "$RUNNER_TEMP/intervaltree-candidate"
- uses: actions/checkout@v4
with:
repository: hexsprite/focuster
ref: master
path: focuster
# Prefer a repo-scoped read token; the existing release token is a fallback.
token: ${{ secrets.FOCUSTER_READ_TOKEN || secrets.RELEASE_PLEASE_TOKEN }}
persist-credentials: false
- name: Install the consumer test dependencies
working-directory: focuster
run: npm ci --ignore-scripts --no-audit --no-fund
- name: Test the packed candidate against Focuster's freelist specs
run: bash intervaltree/scripts/check-focuster-compat.sh "$GITHUB_WORKSPACE/focuster" "$RUNNER_TEMP"/intervaltree-candidate/intervaltree-*.tgz
29 changes: 29 additions & 0 deletions .github/workflows/release-health.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: Release health

on:
push:
branches: [master]
tags: ['v*']
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled, closed]
schedule:
- cron: '15 * * * *'
workflow_dispatch:

permissions:
contents: read
pull-requests: read

jobs:
release-labels:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Check for tagged releases still labeled pending
env:
GITHUB_REPOSITORY: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
run: node scripts/release-health.mjs
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ npx eslint . # Run ESLint (uses @antfu/eslint-config)
- `IntervalTree.merged` means `mergeOverlaps()` would change nothing: no two intervals overlap or touch. Only `add` clears it and only `mergeOverlaps` sets it. `chopAll` and `difference` use it to skip sorting.
- `chop` and `chopAll` treat an empty range (`start === end`) as a no-op and throw on an inverted range.
- Among intervals with identical bounds and different `data`, which one `first()`, `last()`, and `mergeOverlaps()` pick is unspecified by contract.
- Array results and iteration have canonical returned-bound order (start, then end). `toSorted()` is an alias for `toArray()`.
- `src/order.ts` owns comparison and start clipping. `findOneByLengthStartingAt` follows the clipped order used by `searchByLengthStartingAt`, with the smallest end winning a clipped-start tie. Its filter receives the stored interval.
- The library is generic. Busy intervals and free slots are caller interpretations; see `GLOSSARY.md`. Experimental `gaps` stays a free function over `IntervalCollection`.

### Testing Approach

Expand Down
34 changes: 34 additions & 0 deletions GLOSSARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Glossary

intervaltree is a generic interval library. Busy time and free time are caller
interpretations of the same data structure, used in scheduling examples.

| Term | Meaning |
| --- | --- |
| Interval | An immutable half-open stretch `[start, end)` with optional data. Stored intervals always have `start < end`. |
| Bounds | The inclusive start and exclusive end of an interval or query window. |
| Data | The caller's payload. Together with the bounds, its identity determines interval equality. |
| Window | The bounds passed to a query or mutation. An empty window stores nothing; `chop` and `chopAll` treat it as a no-op. |
| Gap | A maximal part of a window covered by no stored interval. It has no source data. |
| Busy interval | A stored interval interpreted as occupied time by a caller. |
| Free slot | A stored interval interpreted as open time by a caller. |
| Overlap | Sharing at least one point. Touching intervals do not overlap. |
| Touch | One interval's end equals the other's start. |
| Merge | Replacing a run of overlapping or touching intervals with one interval. |
| Chop | Cutting a window out of every stored interval, keeping the surviving fragments and their data. |
| Envelop | A window envelops an interval when the interval lies wholly inside it. |
| Contain | An interval contains a point `p` when `start <= p < end`. |
| Canonical order | Ascending start, then end, of returned bounds. Order among identical bounds with different data is unspecified. |
| Merged tree | A tree known to have no overlapping or touching intervals. Removing or chopping intervals preserves that property. |
| Record union | All distinct intervals from two collections, retaining their data. |
| Range union | Record union followed by merging overlapping and touching intervals. |

Length queries search stored intervals with enough length remaining after a
point. They never discover gaps or implicitly merge touching intervals.
Call `mergeOverlaps()` first when touching free slots should count as one.
For a busy-time collection, compute a window's complement with `difference`
before asking a length query for free time.

`gaps` remains an experimental free function in `src/gaps.ts`, operating on
`IntervalCollection`. It belongs outside the storage core; a scheduling
wrapper can be considered if another caller needs booking semantics.
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,11 @@ const enveloped = tree.searchEnveloped(0, 100)
const byLength = tree.searchByLengthStartingAt(3, 5)
// Returns: All intervals of length >= 3 starting at position 5 or later

// Find first interval of minimum length — O(log n) with early termination
// Find the first canonical interval of minimum remaining length
const first = tree.findOneByLengthStartingAt(3, 5)
// Returns: First interval of length >= 3 starting at/after position 5
// If found interval starts before 5, it's adjusted to start at 5
// Ties on the adjusted start choose the smallest end.

// With optional filter function
const filtered = tree.findOneByLengthStartingAt(3, 5, iv => iv.data?.priority === 'high')
Expand Down Expand Up @@ -279,7 +280,7 @@ Since intervals work with numbers, you can use timestamps for date-based interva
```js
const schedule = new IntervalTree()

// Add time slots (using timestamps)
// Store busy time (using timestamps)
const start = new Date('2024-01-01T09:00:00').getTime()
const end = new Date('2024-01-01T10:00:00').getTime()
schedule.addInterval(start, end, 'Morning meeting')
Expand All @@ -290,12 +291,23 @@ const conflicts = schedule.searchPoint(when)

// Find available slots
const dayStart = new Date('2024-01-01T08:00:00').getTime()
const dayEnd = new Date('2024-01-01T17:00:00').getTime()
const freeSlots = IntervalTree.fromTuples([[dayStart, dayEnd]]).difference(schedule)
const minDuration = 60 * 60 * 1000 // 1 hour in milliseconds
const available = schedule.findOneByLengthStartingAt(minDuration, dayStart)
const available = freeSlots.findOneByLengthStartingAt(minDuration, dayStart)
// Returns [08:00, 09:00), before the morning meeting.
```

## API Reference

All array results and iteration use **canonical order**: ascending start,
then end of the returned bounds. Identical bounds with different data are
unordered. `toArray()` is canonical; `toSorted()` remains a compatibility alias.
`findOneByLengthStartingAt(L, t)` selects the first bounds returned by
`searchByLengthStartingAt(L, t)`. Its optional filter receives the original
stored interval. Neither query joins touching intervals; call `mergeOverlaps()`
first if that is the intended interpretation. See [GLOSSARY.md](GLOSSARY.md).

### IntervalTree

**Construction:**
Expand All @@ -316,7 +328,7 @@ const available = schedule.findOneByLengthStartingAt(minDuration, dayStart)
- `searchOverlap(start: number, end: number)` - Find all intervals overlapping a range
- `searchEnveloped(start: number, end: number)` - Find intervals completely within a range
- `searchByLengthStartingAt(length: number, start: number)` - Find intervals by minimum length
- `findOneByLengthStartingAt(minLength: number, startingAt: number, filterFn?: (iv: Interval<T>) => boolean)` - O(log n) first matching interval with optional filter
- `findOneByLengthStartingAt(minLength: number, startingAt: number, filterFn?: (iv: Interval<T>) => boolean)` - First canonical result; O(log n + k) without a filter, where k counts qualifying intervals containing startingAt. A filter can require O(n).

**Navigation:**
- `first(): Interval<T> | null` - Get the earliest interval by start (O(log n))
Expand Down Expand Up @@ -673,7 +685,7 @@ This library uses **half-open intervals** `[start, end)` where:
**Why half-open intervals?**
1. **Length calculation**: Just `end - start` (no off-by-one errors)
2. **Adjacent intervals**: `[1, 5)` and `[5, 10)` don't overlap
3. **Empty intervals**: `[5, 5)` is naturally empty
3. **Stored intervals must have positive length**: constructing `[5, 5)` throws. Empty ranges passed to `chop` or `chopAll` are no-ops.
4. **Consistency**: Matches JavaScript conventions (`Array.slice`, `substring`, etc.)

This follows the recommendation from Edsger W. Dijkstra's 1982 note on interval notation and is used by most programming languages and CS literature.
Expand Down
42 changes: 42 additions & 0 deletions docs/design/collection-ops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Shared collection operations

Decision: retain a generic interval library and use free functions in a future
`src/collectionOps.ts`. This resolves the saved derived-members design.

Share a member only when both adapters would execute the same code and a
literal-value test can pin its behavior. Keep explicit delegating members on
each adapter; introduce no base class or new protected public surface.

The shared functions can cover emptiness, bulk add/remove, interval creation,
tuple/JSON conversion, labeled formatting, iteration, equality, hashing, and
range-union plumbing. Each function depends on only the primitives it reads.
Pin the existing serialized SHA-256 values before moving hash code. The array
adapter should format itself as `ArrayIntervalCollection`, not `IntervalTree`.

Storage, pruned queries, clipping selection, chopping, merging, and set-algebra
algorithms remain independent in the tree and array oracle. Sharing these
would remove the independence that the model check is intended to test.

## Answers to the saved design questions

Empty mutation windows are no-ops; inverted or NaN bounds are errors. This
preserves the explicit empty-range decision made for 2.1.0. A future uniform
query-validation change should apply the same rule, with regression tests and
a breaking release notice: current query methods do not consistently enforce
it. This design resolution does not change their existing error behavior.

Set-algebra inputs can widen to `IntervalCollection<T>` in a minor release,
while each adapter retains its concrete return type. Implementations must read
public size, sorted intervals, and overlap queries instead of another
adapter's private storage. `map` already accepts a callback and can retain its
concrete return type; it needs no collection-input widening.

Canonical iteration and result order are now interface guarantees. `toSorted`
stays as a compatibility alias. Hashes and equality retain the existing
insertion-sensitive behavior for identical bounds with different data.

Implementation is separate from this design decision. Test the shared module
against a minimal primitive stub, adopt it in the oracle while the tree still
has its original code, then adopt it in the tree. Model checks for shared
members must be replaced with properties over independently checked
primitives, rather than comparing the same function against itself.
32 changes: 20 additions & 12 deletions docs/design/gaps.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ the way out.
the range are clipped to it (verified: `[[-5,3],[8,30]]` over `[0,10)` →
`[[3,8)]`).

4. **Invalid range.** Decided: `start >= end` throws `start must be < end`,
matching `chop`'s existing assertion, for consistency across the API.
4. **Invalid window.** The spike requires `start < end`. This differs from
`chop` and `chopAll`, where empty windows are no-ops. Keep this experimental
boundary until a public gaps API is proposed.

5. **`minLength` filter parameter?** Decided: no. Callers already have
`Array.prototype.filter` on the result, and folding length filtering into
Expand Down Expand Up @@ -76,17 +77,24 @@ result-tree construction entirely — it returns a plain array.
## Proposed public signature

```ts
function gaps<T>(tree: IntervalTree<T>, start: number, end: number): Interval<T>[]
function gaps<T>(tree: IntervalCollection<T>, start: number, end: number): Interval<T>[]
```

Free function (not a tree method) is deliberate for the spike, mirroring
`fromTuples`-style ergonomics without adding tree-internal coupling; whether
it should instead be `IntervalTree.prototype.gaps(start, end)` for API
symmetry with `difference`/`chop`/etc. is an open question for the
maintainer, not decided here.
Decision (2026-10-04): keep gaps as a free function over `IntervalCollection`.
The library is generic; busy and free interpretations belong to callers, not
storage types. This helper remains experimental and unexported. See
`GLOSSARY.md` for the terminology.

## Follow-up (out of scope for this spike)
## Length-query duality

If accepted, `searchByLengthStartingAt`'s "first free slot of length L"
logic can likely be re-expressed as a filter over `gaps`, per the plan's
maintenance note. Not attempted here.
For stored busy intervals B and a window W, gaps finds uncovered bounds.
Length queries instead search stored bounds. They agree only after taking the
complement: construct F as W.difference(B), merge F, then compare
`gaps(B, Math.max(t, W.start), W.end).filter(iv => iv.length >= L)` with
`F.searchByLengthStartingAt(L, t)`. This requires t < W.end.

Neither operation replaces the other on the same collection. Length queries
do not join touching stored intervals; callers should merge them first when
that is their intended interpretation. Focuster stores free slots and chops
bookings out, so its existing length queries are appropriate. A scheduling
wrapper or first-gap convenience API can wait for a concrete second caller.
35 changes: 35 additions & 0 deletions docs/design/release-checks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Release checks

The release CI job packs this checkout and installs the tarball into a fresh
Focuster checkout. It runs the actual `imports/schedule/domain/freelist` Vitest
specs, using Focuster's own configuration and Meteor stubs. The check covers
the generic collection API used by the scheduler; it does not exercise the
Meteor server or browser.

Focuster is private. Configure `FOCUSTER_READ_TOKEN` with read-only contents
access to `hexsprite/focuster`. Until that token is supplied, checkout uses the
existing `RELEASE_PLEASE_TOKEN`, which must also be allowed to read Focuster.
An authentication failure fails the job. The private checkout runs only for
release-please branches in this repository, and checkout does not persist
credentials. Dependency and test steps receive neither token in their env.

For a local check, install Focuster's normal dependencies, pack intervaltree,
then run:

```bash
pnpm pack --pack-destination /tmp/intervaltree-candidate
bash scripts/check-focuster-compat.sh /absolute/path/to/focuster /tmp/intervaltree-candidate/intervaltree-2.1.0.tgz
```

The script installs the self-contained distribution in an isolated directory,
temporarily replaces only the installed intervaltree package, and restores it
on exit. It preserves the consumer's manifest, lockfile, and other installed
dependencies. A candidate with runtime dependencies fails explicitly.

The separate release-health workflow checks all PR pages, including merged
PRs. A pending release whose `v<version>` tag exists fails with its PR URL.
Closed PRs that were never merged are abandoned and do not block releases.
Unknown release titles and GitHub permission/network errors also fail; only
a tag lookup returning HTTP 404 counts as a tag that does not exist. It runs
on PR label/close events, branch/tag pushes, hourly, and on manual dispatch.
It reports stale state without editing labels or triggering publication.
3 changes: 3 additions & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import antfu from '@antfu/eslint-config'
export default antfu({
typescript: true,
ignores: ['dist/**', 'node_modules/**', '*.md', '*.json', '.github/**', '.beads/**', '.claude/**', '.agents/**', '.gc/**'],
}, {
files: ['test/*.test.mjs'],
rules: { 'test/no-import-node-test': 'off' },
}, {
// antfu's default enforces trustPolicy: no-downgrade, which rejects the
// current lockfile. Only require the setting this file exists for.
Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"packageManager": "pnpm@12.4.0",
"scripts": {
"build": "npx tsup",
"postbuild": "node -e \"const fs=require('fs');for(const f of ['dist/index.js','dist/index.cjs']){const s=fs.readFileSync(f,'utf8');if(/DEBUG = true/.test(s)||!s.includes('INTERVALTREE_DEBUG'))throw new Error(f+': debug flag frozen on or missing')}\"",
"postbuild": "node scripts/check-dist-debug.mjs",
"clean": "rimraf dist",
"prebuild": "rimraf dist",
"prepare": "npm run build",
Expand All @@ -46,7 +46,8 @@
"model-check": "vitest run --config vitest.model.config.ts",
"test:all": "pnpm test && pnpm model-check",
"bench": "NODE_ENV=production vitest bench --run --config vitest.bench.config.ts",
"test:compat": "rm -rf /tmp/itc && mkdir -p /tmp/itc && pnpm build && pnpm pack --pack-destination /tmp/itc >/dev/null && mkdir -p /tmp/itc/app && cp test/compat.cjs test/compat.mjs /tmp/itc/app/ && cd /tmp/itc/app && npm init -y >/dev/null && npm i --no-audit --no-fund /tmp/itc/intervaltree-*.tgz >/dev/null && node compat.cjs && node compat.mjs"
"test:compat": "rm -rf /tmp/itc && mkdir -p /tmp/itc && pnpm build && pnpm pack --pack-destination /tmp/itc >/dev/null && mkdir -p /tmp/itc/app && cp test/compat.cjs test/compat.mjs /tmp/itc/app/ && cd /tmp/itc/app && npm init -y >/dev/null && npm i --no-audit --no-fund /tmp/itc/intervaltree-*.tgz >/dev/null && node compat.cjs && node compat.mjs",
"test:automation": "node --test test/*.test.mjs"
},
"devDependencies": {
"@antfu/eslint-config": "^6.7.3",
Expand Down
Loading
Loading