Tamale is a minimal kernel for preserving user edits/intervenes across upstream regeneration(e.g. user modify/override mechine-generate content).
It separates rebasing into two questions during upstream regenerate:
- Did the edited thing survive the upstream change?
- Does the edit still apply to the new content?
After each regeneration every patch must be judged still applicable / applicable after transform / dead.
Tamale is a greenfield successor designed from a review/reimplementation of zongzi.
Let's Start with an upstream document.
source = %{s1: "Hello world.", s2: "Soy tamal."}
{:ok, space} = Space.new([:s1, :s2])The user creates a translation for the original text(s1):
anchor = %Ordinal{
refs: [:s1],
at_version: space.version
}
{:ok, patch} =
Patch.new(
source.s1,
%{lang: :zh, text: "你好,世界。"}
)
{:ok, patch2} =
Patch.new(
source.s2,
%{lang: :zh, text: "这里粽子(墨西哥甜粽)。"}
)At this point, the patchs mean:
Apply
你好,世界。tos1, but only ifs1is still based onHello world.. Also apply这里粽子(墨西哥甜粽)。tos2, withs2==Soy tamal..
Suppose the upstream generator splits s1:
{:ok, space} =
Space.apply_op(
space,
%Tamale.Op.Split{
id: :s1,
children: [:s1, :s1b]
}
)
source = %{
s1: "Hello",
s1b: " world.",
s2: source.s2
}The important part is that the identity of the first child survives the split:
s1
├── s1 ← original identity survives
└── s1b
Then transport the user's anchor.
{:ok, anchor} = Transport.transport(anchor, space)The anchor survived the structural change, so the translation is still attached to s1.
Dive to 2nd phase, check whether the patch still applies.
case Patch.resolve(patch, source.s1) do
{:ok, payload} ->
IO.puts("APPLY: #{payload.text}")
{:conflict, :base_changed} ->
IO.puts("CONFLICT: the source changed")
{:error, reason} ->
IO.puts("ERROR: #{inspect(reason)}")
endThe result is:
CONFLICT: the source changed
This is intentional.
The anchor survived the split, but s1 is no longer the text the user originally edited:
base: "Hello world."
current: "Hello"
So Tamale reports:
structural survival → yes
semantic survival → no
That distinction is the core of Tamale's two-phase survival model.
Tamale couldn't works without your task, so it needs combination with kernel, policy & adapters/host.
kernel : Space(id, order, version) · Op · Anchor/Transport · Patch ← this package
policy : relocation choice, clip-vs-conflict, digest chunk granularity ← callbacks
adapters : Tempo→Warp · curve samplers · windowing · score theory · engine bindings
The kernel holds no domain data and no engine contract.
Tamale has four small building blocks:
-
Tamale.Space— where things liveA
Spaceis the versioned world being edited. It gives stable ids to objects and records every change as anOpin a linear log.{:ok, space} = Space.new([:a, :b, :c])
After an edit, the space gets a new version and the edit is added to its log. The log is what lets Tamale move old anchors through later changes.
-
Tamale.Op— what changedAn
Opdescribes an edit explicitly:Insert Delete Split Merge Move RetimeTamale works from these edit intents rather than trying to infer changes by comparing two states.
A raw
diff(old, new)adapter exists for callers that only have snapshots, but it is a fallback rather than the kernel's source of truth. -
Tamale.Anchor+Tamale.Transport— where an edit should goA patch is attached to an
Anchor, not directly to a particular version of the source.When the source changes,
Transportmoves that anchor through theSpace's op log:{:ok, anchor} {:clip, covered, lost} {:ambiguous, candidates} {:undefined, reason}
Tamale supports three anchor shapes:
Ordinal— identifies objects and their structural position.Metric— identifies coordinate intervals and moves through aTamale.Warp.Relative— identifies an interval relative to another object.
Coordinates use exact rationals (
Tamale.Coord); floats are rejected. -
Tamale.Patch— whether the edit still appliesA patch is a payload together with the digest of the content it was created from:
patch = (base_digest, payload)Resolving a patch is deliberately strict:
{:ok, payload} {:conflict, :base_changed}
If the current content has the same digest as the original base, the patch applies. Otherwise, it conflicts.
There is no fuzzy matching or tolerance knob in the kernel.
The whole flow is:
Op
│
▼
Space ──────► new version
│
│ transport
▼
Anchor
│
│ locate
▼
Patch
│
│ resolve
▼
apply / conflict
This gives Tamale two deliberately separate questions:
1. Did the edited location survive the upstream change?
→ Anchor + Transport
2. Does the edit still apply to the new content?
→ Patch + Digest
That separation is the core of Tamale's two-phase survival model.
- Edit intent is first-class; heuristics live only in the
difffallback. - Structural survival (transport, at edit time) and semantic survival
(
Patch.resolve, at render time) are separate phases. - No tolerance knobs; conflicts surface explicitly.
- Single writer: one linear log. (Offline/collaboration would reintroduce tombstones — as a deliberate extension, not a heuristic.)
- Kernel conventions, not policy: a split's first child inherits the
parent id; a merge's
intoishd(ids); ids are never reused.
Working and tested:
Spaceop application with validation, versioning, log, truncationTransportfor all three anchor shapes:Ordinal(delete/split/merge/move/retime, conjunctive refs, head-state adjacency,boundary_mergedwhen a merge collapses anadjacent?anchor's refs, truncated/future versions)Metric(warp-fold transport; warps come from a Caller provider — the kernel holds no spans; partial survival surfaces as first-class{:clip, covered, lost}; the folded warp is available viaTransport.fold_warp/4forChannelAdapter.warp_payload/2)Relative(Ordinal-rule host transport; absolute interval derived viaAnchor.project/3; offsets may be negative and overhang the host)
Warpalgebra over exact rational coordinates (Tamale.Coord):from_segments/1(monotonicity-validated assembly),compose/2,invert/1,map_interval/2— a 1/3 tempo produces thirds, never float dustPatchdigest resolve over canonical digests (Tamale.Digest— floats/structs/tuples rejected; atom keys encoded by name; spec + worked examples indocs/spec/canonical-digest.md)ChannelAdapter.warp_payload/2— the single channel-adapter callback- JSON conformance vectors (
test/conformance/, format v1): 40 scenarios across space/ordinal/metric/relative/digest/resolve, seeded from zongzi'sGOLDEN_SCENARIOS.mdincluding the deliberate semantic flips (G-AN-02 merge, G-INT-05 seconds anchor). Coordinates travel as integers or"num/den"strings; the metric family pins exact rational arithmetic (thirds, composed fractional scales). The Elixir implementation is now the reference runner; other languages implement against the vectors.
Guides and specs:
docs/zh/guide/caller-guide-zh.md— the Caller orchestration contract (also the equinox migration manual): trio layout, edit-loop op conventions, two-phase survival, warp/digest obligations, engine protocol requirements, self-check listdocs/spec/canonical-digest.md— portable digest spec v1
Done (implemented in the downstream coconut editor core):
- Warp-provider reference example —
Coconut.Edit.WarpProviderconstructs tick/frame warps from tempo maps and span tables, including theT_new ∘ W_tick ∘ T_old⁻¹composition for frame-addressed, score-following anchors. diff(old, new)fallback adapter —Coconut.Edit.Diffinfers the six canonical ops from raw state pairs for import/reload/collaboration.
Not yet:
- Chunked digest helper — the pattern is settled
(
docs/decisions/0006); an optional helper module may follow when projection scale makes monolithic digest materialization expensive.
Design decisions: docs/decisions/.
MIT (same as zongzi).