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.4andexpo_+0.5. With Path C, agent generatesexpo_+0.42_sessionon 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
.dtstylecapture; 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):
ExposureEncoder— exposure module. Phase 0 demonstrated feasibility. ~10-20 lines of encoder logic.ColorCalibrationWBEncoder— color calibration WB axis. ~50-100 lines (more complex struct).ToneEqualizerEncoder— tone equalizer per-zone adjustment. ~100-150 lines.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):
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_modversionsdeclares 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.jsonlaccumulating 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_uuidare 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.dtstyleis more "stable" than a generatedexpo_+0.42_session. The agent should prefer hand-authored when available. Discoverable via the manifest'ssourcefield.
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).
Links¶
- 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)