Skip to content

RFC-012 — Programmatic vocabulary generation (Path C)

Status · Decided (v1.4.0) Date · 2026-05-02 TA anchor ·/components/synthesizer ·/constraints/opaque-hex-blobs Related · ADR-001, ADR-008, RFC-001 Closes into · ADR-073 (programmatic vocabulary authoring via reverse-engineered structs) Why this is an RFC · ADR-008 commits to opaque-blob handling. ADR-001's "Architecture A" (hex param manipulation) was rejected for v1, but Phase 0 testing surfaced that it's feasible for high-value modules — exposure specifically. The TODO entry on Path C captured this. v1.4.0 supplied the missing evidence: 31 entries authored programmatically across 9 darktable iop modules with 22 e2e direction-of-change tests passing. ADR-073 formalizes the technique as an accepted complement to hand-authoring.

The question

Hex op_params manipulation can produce vocabulary entries that the GUI cannot author cleanly (e.g., literal-zero exposure, continuous-value fine-tuning). For a small set of modules with simple param structures, this is feasible without abandoning ADR-008's "opaque-blob principle for everything else."

When does Path C become valuable? Which modules? What's the architectural shape? When evidence shows the coarse vocabulary granularity is actually limiting, this RFC's outlined approach is what gets implemented.

Use cases

  • Photographer's brief calls for "+0.42 EV." Vocabulary has expo_+0.4 and expo_+0.5. With Path C, agent generates expo_+0.42_session on the fly.
  • Photographer wants expo_+0.0 (literal zero). darktable's GUI minimum granularity is ~0.009 EV. Path C generates the literal zero.
  • Color cal WB has many useful axes (temperature, tint, saturation per color); pre-authoring all combinations is intractable. Path C generates per-axis adjustments on demand.

Goals

  • Programmatic generation feasible for a small set of high-value modules
  • Generated entries integrate with the regular vocabulary system (same parser, same SET semantics)
  • Vocabulary entries declare whether they're hand-authored or programmatic
  • Implementation cost bounded; only specific modules invest the engineering

Constraints

  • TA/constraints/opaque-hex-blobs — most modules stay opaque
  • ADR-008 — generic vocabulary is .dtstyle capture; Path C is per-module exception
  • ADR-001 — Architecture A was rejected for v1; Path C is an enrichment, not a replacement

Proposed approach

A small ProgrammaticVocabularyGenerator subsystem with per-module encoders.

Architectural shape:

@dataclass
class ProgrammaticEntry:
    name: str
    operation: str
    modversion: int
    multi_priority: int
    blendop_params: str   # could be a constant default for simple cases
    blendop_version: int
    op_params: str        # generated by per-module encoder
    iop_order: float | None   # known per module


class ParamEncoder(Protocol):
    @property
    def operation(self) -> str: ...
    @property
    def supported_modversions(self) -> list[int]: ...

    def encode(self, params: dict) -> str:
        """Returns hex op_params string."""

    def describe(self, params: dict) -> str:
        """Returns human-readable summary (e.g., 'exposure +0.42 EV')."""


class ExposureEncoder:
    @property
    def operation(self) -> str: return "exposure"

    @property
    def supported_modversions(self) -> list[int]: return [7]

    def encode(self, params: dict) -> str:
        # struct: [8 bytes padding][4 bytes float exposure][16 bytes more]
        # the 4-byte float at offset 8 is the EV value
        ev = params["ev"]
        prefix = bytes(8)
        ev_bytes = struct.pack("<f", ev)
        suffix = bytes.fromhex("00004842000080c00100000001000000")
        return (prefix + ev_bytes + suffix).hex()

    def describe(self, params: dict) -> str:
        return f"exposure {params['ev']:+.2f} EV"

Initial encoder set (proposed):

  1. ExposureEncoder — exposure module. Phase 0 demonstrated feasibility. ~10-20 lines of encoder logic.
  2. ColorCalibrationWBEncoder — color calibration WB axis. ~50-100 lines (more complex struct).
  3. ToneEqualizerEncoder — tone equalizer per-zone adjustment. ~100-150 lines.
  4. ColorBalanceRgbShadowsEncoder — color balance rgb shadow adjustment. ~100-150 lines.

These are the modules where continuous-value control matters most for taste articulation. The list is intentionally short.

Tool surface (extension to ADR-033):

generate_primitive(operation, params, name?) → primitive_descriptor

The agent calls this when it wants a programmatic primitive. Returns a synthetic PluginEntry-like descriptor. The session tracks generated primitives in a per-session pool; they don't persist to disk by default.

If the photographer accepts the primitive (via propose-and-confirm), it's saved to ~/Pictures/Chemigram/<image_id>/generated_vocabulary/<name>.dtstyle plus a manifest entry.

Alternatives considered

  • Generate vocabulary entries by full struct definition (e.g., parse C headers automatically): rejected — even with codegen, per-module struct evolution requires per-module maintenance. Hand-coding 4-5 high-value encoders is more reliable than auto-generating 60.

  • Hosted service for param encoding: rejected — adds dependency, network round-trip, BYOA violation.

  • Use a different darktable-version pinning per encoder: rejected — the modversion-pinning per encoder is the simpler approach. When darktable bumps a module's modversion, that encoder's supported_modversions declares the supported list; engine refuses generation for unsupported versions.

  • Defer Path C entirely (never implement): considered. Phase 0 evidence makes it tractable; usage evidence will tell us when it matters. Outline now; implement when the bottleneck is real.

Trade-offs

  • Per-module engineering cost — each encoder is its own work. The cost is bounded by the number of modules; we resist expanding the list.
  • Modversion drift — a darktable update can break an encoder. Maintenance cost.
  • Generated primitives might be uncomfortable to share (where do they live? how are they versioned?). Mitigated: they're per-session pool by default; promotion to persistent is an explicit step.

Open questions

  • What's the right trigger for implementing each encoder? Vocabulary gaps in vocabulary_gaps.jsonl accumulating around a specific module? Photographer requests "I keep wanting to fine-tune exposure by 0.05 EV — make it easier"? Subjective; document the criterion in CONTRIBUTING.md.
  • Naming of generated entries. Auto-generated names like expo_+0.42_session_uuid are ugly. Photographer-suggested names are nicer but optional. Proposed: agent suggests names from intent ("warm_subject_subtle"), photographer can override.
  • Relationship between programmatic and hand-authored. A hand-authored expo_+0.5.dtstyle is more "stable" than a generated expo_+0.42_session. The agent should prefer hand-authored when available. Discoverable via the manifest's source field.

How this closes

This RFC stays open as a placeholder until v1 evidence shows specific encoders are needed. When triggered: - An ADR for the ProgrammaticVocabularyGenerator subsystem architecture. - Per-encoder ADRs as each is implemented (specifying struct layout, modversion support, edge cases).

  • TA/components/synthesizer
  • TA/constraints/opaque-hex-blobs
  • ADR-001, ADR-008
  • RFC-001 (synthesizer architecture; Path C generated entries flow through same synthesizer)
  • docs/TODO.md/Programmatic vocabulary entry generation (Path C)