Skip to content

ADR-009 — Path A vs Path B for synthesis

Status · Accepted Date · 2026-04-27 TA anchor ·/components/synthesizer ·/contracts/xmp-darktable-history Related RFC · RFC-001

Context

The synthesizer applies a vocabulary entry to an XMP either by replacing an existing history entry (the common case — most user moves replace darktable's auto-applied _builtin_* defaults) or by adding a new entry (the less-common case — adding a module not in the baseline pipeline, or adding a second instance of a module at a different multi_priority).

Phase 0 experiment 4 surfaced that these two paths have different requirements regarding iop_order (the float that determines pipeline position).

Decision

The synthesizer has two paths, dispatched on whether the vocabulary entry's (operation, multi_priority) matches an existing XMP entry:

Path A — Replace. When (operation, multi_priority) matches an existing entry: - Replace op_params, enabled, blendop_params, blendop_version, multi_name (set to empty string for user-authored entries) - Keep the existing darktable:num and do NOT supply iop_order — the replacement inherits the pipeline position of the entry it replaces - Update <darktable:history_end> only if the entry count changes (it doesn't, in Path A)

Path B — Add new instance. When (operation, multi_priority) does NOT match any existing entry: - Append a new <rdf:li> with the next available darktable:num - Must supply darktable:iop_order — copied from the source .dtstyle file's <iop_order> element (note: locale-dependent comma → period conversion may be needed: 47,47474747.474747) - If iop_order is omitted, darktable emits "cannot get iop-order for <operation> instance N" and silently drops the entry - Increment <darktable:history_end>

Rationale

  • Path A is the dominant case (replacing _builtin_* defaults with user vocabulary). Keeping it simple — no iop_order math — matches its frequency.
  • Path B is needed for additive moves (multi-instance modules, drawn-mask gradients added on top, etc.). Requiring explicit iop_order matches darktable's actual requirement.
  • Phase 0 testing established this empirically: experiment 4 iteration 1 added a new instance without iop_order and the entry was silently dropped; iteration 2 used Path A (replacement) and the render was correct; iteration 3 confirmed by changing op_params alone and observing visible effect.

Alternatives considered

  • Always use Path B (always add, never replace): rejected — produces unintended layering, conflicts with SET semantics (ADR-002), accumulates history forever.
  • Always use Path A, error on no-match: rejected — would prevent legitimate Path B use cases like adding a drawn-mask gradient on top of the baseline.
  • Compute iop_order ourselves rather than copying from .dtstyle: rejected — darktable's pipeline ordering is internal and version-specific. Copying from .dtstyle (where darktable already wrote a valid value) is reliable.

Consequences

Positive: - Most vocabulary applications go through the simpler Path A - Path B is available when needed (multi-instance, additive moves) - iop_order source-of-truth is the .dtstyle file, not engine logic

Negative: - The synthesizer must implement two paths and dispatch correctly between them - A vocabulary entry's multi_priority value affects whether application is a replace or an add — authors must understand this when their entry should layer rather than replace

Implementation notes

src/chemigram_core/xmp.py.synthesize_xmp() looks up (operation, multi_priority) in the existing history; dispatches to _replace_entry() or _append_entry() accordingly. Locale-related decimal-separator handling for iop_order lives in dtstyle.py.parse_dtstyle(). See RFC-001 for the full synthesizer architecture.