File size: 10,774 Bytes
fed954e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
"""
registry.py — The slot registry.

This is the source of truth for the caption schema. Every slot the system
knows about lives here as a SlotSpec entry. The Pydantic Caption model, the
JSON Schema export, the GBNF grammar, and the evaluator's grounding rules
are all derived from this registry at import time.

Adding a slot is one dict entry. Adding a category is one Literal expansion.
No code outside this file should hardcode slot names or category logic.

Slot taxonomy (the three categories that came out of the baseline analysis):
  - descriptive : grounded in the input caption. Hallucination forbidden.
                  Examples: subjects, actions, setting.
  - aesthetic   : how the scene should look. Often empty in input;
                  legitimate inference (or null) in enhancement mode.
                  Examples: style, lighting, palette.
  - semantic    : interpretive meaning. Inferential by definition.
                  Examples: mood, implication, narrative_function.

Groundedness rules (drive the evaluator):
  - must_ground   : every leaf MUST trace to the input caption.
  - may_infer     : leaf may be grounded OR inferred; both are acceptable.
  - derived_only  : leaf is expected to be inferred. Grounding check skipped.
"""

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Literal, Optional, Type

from pydantic import BaseModel, Field


# ──────────────────────────────────────────────────────────────────────────────
# Slot-level enums. Adding a value here is a registry-only change; no
# code outside this file matches on these strings directly (the helpers below
# encapsulate all behavior).
# ──────────────────────────────────────────────────────────────────────────────

Category = Literal["descriptive", "aesthetic", "semantic"]
Cardinality = Literal["single", "list"]
Vocabulary = Literal["closed", "open"]
Groundedness = Literal["must_ground", "may_infer", "derived_only"]

# value_kind selects the leaf's primitive type for code generation. "string" is
# the caption default (every existing slot). The numeric kinds exist so the SAME
# registry→Pydantic→JSON-Schema→GBNF machinery can describe vision outputs
# (bounding boxes, confidences, depths) without a second codegen path.
#   string  → str
#   number  → float   (optionally bounded via number_range)
#   integer → int
#   bbox    → list[float] of length 4   (x1,y1,x2,y2 or x,y,w,h — see coords.py)
#   point   → list[float] of length 2
ValueKind = Literal["string", "number", "integer", "bbox", "point"]


# ──────────────────────────────────────────────────────────────────────────────
# Nested value models. Used by slots whose value is structured (e.g. subjects
# have a name and a list of attributes). New nested types go here and are
# referenced from the SlotSpec via `nested_model=`.
# ──────────────────────────────────────────────────────────────────────────────

class SubjectValue(BaseModel):
    """A single entity in the caption."""
    name: str = Field(..., min_length=1, max_length=64)
    # No max_length on attributes: rich captions (JoyCaption prose, booru tag
    # strings) legitimately carry >8 per subject, and the cap was rejecting 44%
    # of otherwise-valid structs in the 100-row bench (2026-07).
    attributes: list[str] = Field(default_factory=list)


# ──────────────────────────────────────────────────────────────────────────────
# SlotSpec — the unit of the registry.
# ──────────────────────────────────────────────────────────────────────────────

@dataclass(frozen=True)
class SlotSpec:
    """Declarative description of one schema slot.

    The structural axes (cardinality, vocabulary, value_kind, nested_fields,
    nested_model) are what drive code generation in schema.py — these apply
    equally to caption slots and to vision-task fields. The caption-only axes
    (category, groundedness) drive the text evaluator and default to neutral
    values so vision per-category registries can omit them.

        cardinality    — single value vs list
        vocabulary     — open (any string) vs closed (one of `closed_values`)
        value_kind     — leaf primitive type (string / number / integer / bbox / point)
        nested_model   — BaseModel subclass for a structured value (caption: SubjectValue)
        nested_fields  — declarative nested-object fields; the generalized form of
                         nested_model — schema.py builds both the Pydantic model and
                         the GBNF object rule recursively from these
        category       — taxonomy bucket (caption prompts); ignored by vision
        groundedness   — strict / soft / never (text evaluator); ignored by vision
        optional       — may the model emit null/[] when empty
        number_range   — (min, max) bound for numeric value_kinds (Pydantic ge/le)
    """
    name: str
    cardinality: Cardinality
    vocabulary: Vocabulary
    category: Category = "descriptive"
    groundedness: Groundedness = "may_infer"
    value_kind: ValueKind = "string"
    closed_values: tuple[str, ...] = ()
    nested_model: Optional[Type[BaseModel]] = None
    nested_fields: tuple["SlotSpec", ...] = ()
    optional: bool = True
    max_items: int = 8           # only for cardinality == "list"
    max_str_length: int = 64     # for open-vocab strings
    number_range: Optional[tuple[float, float]] = None

    def __post_init__(self):
        # Lightweight validation — catch registry mistakes at import time
        if self.vocabulary == "closed" and not self.closed_values:
            raise ValueError(f"slot {self.name!r}: closed vocab requires closed_values")
        if self.vocabulary == "open" and self.closed_values:
            raise ValueError(f"slot {self.name!r}: open vocab cannot have closed_values")
        if self.nested_model is not None and self.vocabulary == "closed":
            raise ValueError(f"slot {self.name!r}: nested_model is incompatible with closed vocab")
        if self.nested_fields and self.nested_model is not None:
            raise ValueError(f"slot {self.name!r}: nested_fields and nested_model are mutually exclusive")
        if self.nested_fields and self.vocabulary == "closed":
            raise ValueError(f"slot {self.name!r}: nested_fields is incompatible with closed vocab")


# ──────────────────────────────────────────────────────────────────────────────
# THE REGISTRY.
#
# Starter set: 5 slots that exercise all three categories and both
# groundedness extremes. Adding a slot is a single entry below.
# ──────────────────────────────────────────────────────────────────────────────

SLOT_REGISTRY: dict[str, SlotSpec] = {
    "subjects": SlotSpec(
        name="subjects",
        category="descriptive",
        cardinality="list",
        vocabulary="open",
        groundedness="must_ground",
        nested_model=SubjectValue,
        max_items=8,
    ),
    "actions": SlotSpec(
        name="actions",
        category="descriptive",
        cardinality="list",
        vocabulary="open",
        groundedness="must_ground",
        max_items=8,
    ),
    "setting": SlotSpec(
        name="setting",
        category="descriptive",
        cardinality="single",
        vocabulary="closed",
        # `may_infer` because Qwen reliably guesses indoor/outdoor from cues
        # even when the caption doesn't say. The grammar pins the value to
        # the enum anyway.
        groundedness="may_infer",
        closed_values=("indoor", "outdoor", "unknown"),
        optional=False,   # always required; the enum includes "unknown" as escape
    ),
    "style": SlotSpec(
        name="style",
        category="aesthetic",
        cardinality="single",
        vocabulary="open",
        groundedness="may_infer",
    ),
    "mood": SlotSpec(
        name="mood",
        category="semantic",
        cardinality="single",
        vocabulary="open",
        # Baseline finding: mood is 73% of all hallucinations under the old
        # rule. Reclassifying it as derived_only stops penalizing the model
        # for inferring; it's correct behavior now, not error.
        groundedness="derived_only",
    ),
}


# ──────────────────────────────────────────────────────────────────────────────
# Query helpers. Use these instead of poking SLOT_REGISTRY directly so behavior
# stays centralized.
# ──────────────────────────────────────────────────────────────────────────────

def slots_by_category(category: Category) -> list[SlotSpec]:
    return [s for s in SLOT_REGISTRY.values() if s.category == category]


def slot_names() -> list[str]:
    """Slot names in registry-declaration order. JSON output uses this order."""
    return list(SLOT_REGISTRY.keys())


def get_slot(name: str) -> SlotSpec:
    if name not in SLOT_REGISTRY:
        raise KeyError(f"unknown slot: {name!r}")
    return SLOT_REGISTRY[name]


# Set of closed-vocab values across all slots — used by the evaluator as the
# "always grounded" allowlist for the `may_infer` closed-vocab case.
def all_closed_vocab() -> set[str]:
    out: set[str] = set()
    for s in SLOT_REGISTRY.values():
        out.update(s.closed_values)
    return out