zandervdm commited on
Commit
16e5bd8
·
verified ·
1 Parent(s): cb3fb46

Bundle the qevi inference engine in-repo

Browse files
Files changed (1) hide show
  1. qevi/pack.py +173 -0
qevi/pack.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Packed multi-question sequence construction for the Qevi architecture spike.
2
+
3
+ Builds, from an image token count and a list of typed questions, the packed sequence layout
4
+ specified in plan/02-ARCHITECTURE.md section 3: one image prefix followed by one context plus
5
+ readout segment group per question, under a block-diagonal attention mask so that questions
6
+ cannot see each other and options within one question cannot see each other.
7
+
8
+ Model-agnostic. Produces a 4D additive attention bias and a position id tensor that any
9
+ transformers causal LM accepts as ordinary keyword arguments.
10
+
11
+ SCOPE LIMIT, current as of 19 September 2026: position_ids here is a single scalar stream,
12
+ correct for plain 1D-RoPE decoders. Qwen3-VL-2B-Instruct, the settled primary base model, uses
13
+ 3D multimodal RoPE (three streams: temporal, height, width; see
14
+ research/18-qwen3-vl-2b-verification.md section 6). For text segments this scalar stream
15
+ transfers directly, since Qwen3-VL gives all three axes the identical value for text tokens; a
16
+ caller targeting Qwen3-VL broadcasts this module's scalar position_ids across all three axes for
17
+ text positions. For the image segment itself, this module's arange(image_len) placeholder does
18
+ NOT match Qwen3-VL's real 2D grid convention and must be replaced with the model's own
19
+ get_vision_position_ids output before a real forward pass on that model. Not yet done here.
20
+ """
21
+
22
+ from dataclasses import dataclass, field
23
+ from typing import Literal
24
+
25
+ import torch
26
+
27
+ NEG_INF = torch.finfo(torch.float32).min
28
+
29
+
30
+ @dataclass
31
+ class Segment:
32
+ name: str
33
+ start: int
34
+ end: int # exclusive
35
+ kind: Literal["image", "context", "readout"]
36
+ question_idx: int | None = None
37
+ readout_idx: int | None = None
38
+
39
+ @property
40
+ def length(self) -> int:
41
+ return self.end - self.start
42
+
43
+ @property
44
+ def sentinel_pos(self) -> int:
45
+ """Absolute index of the last token in this segment, where the typed head reads."""
46
+ return self.end - 1
47
+
48
+
49
+ @dataclass
50
+ class Question:
51
+ qtype: Literal["noul", "choice", "score"]
52
+ context_len: int
53
+ readout_lens: list[int] = field(default_factory=lambda: [1])
54
+
55
+
56
+ @dataclass
57
+ class PackedBatch:
58
+ seq_len: int
59
+ segments: list[Segment]
60
+ attn_bias: torch.Tensor # (1, 1, seq_len, seq_len) additive, 0 = allowed, NEG_INF = blocked
61
+ position_ids: torch.Tensor # (1, seq_len)
62
+ sentinel_positions: dict # (question_idx, readout_idx) -> absolute position
63
+
64
+
65
+ def _build_segments(image_len: int, questions: list[Question]) -> list[Segment]:
66
+ segments = [Segment("V", 0, image_len, kind="image")]
67
+ cursor = image_len
68
+ for qi, q in enumerate(questions):
69
+ c_start = cursor
70
+ c_end = c_start + q.context_len
71
+ segments.append(Segment(f"C{qi}", c_start, c_end, kind="context", question_idx=qi))
72
+ cursor = c_end
73
+ for ri, rlen in enumerate(q.readout_lens):
74
+ r_start = cursor
75
+ r_end = r_start + rlen
76
+ segments.append(
77
+ Segment(f"R{qi}_{ri}", r_start, r_end, kind="readout", question_idx=qi, readout_idx=ri)
78
+ )
79
+ cursor = r_end
80
+ return segments
81
+
82
+
83
+ def _group_key(seg: Segment):
84
+ """Two positions may attend to each other (subject to causal order) only if they share a
85
+ group. A readout's group includes its own context segment, which is why a readout sees all
86
+ of C_i without needing a separate 'full attention to context' rule: C_i is already earlier
87
+ in sequence order, so ordinary intra-group causal masking gives it full visibility."""
88
+ if seg.kind == "image":
89
+ return ("V",)
90
+ if seg.kind == "context":
91
+ return ("Q", seg.question_idx)
92
+ return ("Q", seg.question_idx, seg.readout_idx)
93
+
94
+
95
+ def _segment_at(segments: list[Segment], pos: int) -> Segment:
96
+ for seg in segments:
97
+ if seg.start <= pos < seg.end:
98
+ return seg
99
+ raise IndexError(pos)
100
+
101
+
102
+ def build_packed_batch(
103
+ image_len: int,
104
+ questions: list[Question],
105
+ position_scheme: Literal["reset", "monotonic"] = "reset",
106
+ text_start_offset: int | None = None,
107
+ ) -> PackedBatch:
108
+ """text_start_offset is the position value each question's context segment resets to under
109
+ the 'reset' scheme. Defaults to image_len, correct for a plain 1D-RoPE decoder where the
110
+ image occupies image_len sequential positions. It is WRONG for M-RoPE models such as
111
+ Qwen3-VL, whose own convention resumes text at max(grid_h, grid_w) // spatial_merge_size,
112
+ not the raw image token count; see research/18-qwen3-vl-2b-verification.md section 6. Callers
113
+ targeting such a model must compute the correct value themselves (typically via the model's
114
+ own get_vision_position_ids or equivalent) and pass it here explicitly."""
115
+ if text_start_offset is None:
116
+ text_start_offset = image_len
117
+ segments = _build_segments(image_len, questions)
118
+ seq_len = segments[-1].end
119
+
120
+ bias = torch.full((seq_len, seq_len), NEG_INF, dtype=torch.float32)
121
+ for i in range(seq_len):
122
+ seg_i = _segment_at(segments, i)
123
+ group_i = _group_key(seg_i)
124
+ for seg_j in segments:
125
+ group_j = _group_key(seg_j)
126
+ same_group = group_i == group_j
127
+ is_image = seg_j.kind == "image"
128
+ # A readout's group is (Q, qi, ri); its context segment's group is (Q, qi).
129
+ # Give the readout visibility into its own context by matching on question index.
130
+ same_question_context = (
131
+ seg_i.kind == "readout" and seg_j.kind == "context" and seg_i.question_idx == seg_j.question_idx
132
+ )
133
+ if not (same_group or is_image or same_question_context):
134
+ continue
135
+ j_start = seg_j.start
136
+ j_end = min(seg_j.end, i + 1) # causal: never attend beyond position i
137
+ if j_end > j_start:
138
+ bias[i, j_start:j_end] = 0.0
139
+
140
+ attn_bias = bias.unsqueeze(0).unsqueeze(0) # (1, 1, seq_len, seq_len)
141
+
142
+ position_ids = torch.zeros(seq_len, dtype=torch.long)
143
+ if position_scheme == "monotonic":
144
+ position_ids[:] = torch.arange(seq_len)
145
+ else:
146
+ position_ids[: image_len] = torch.arange(image_len)
147
+ for qi, q in enumerate(questions):
148
+ c_seg = next(s for s in segments if s.kind == "context" and s.question_idx == qi)
149
+ local_len = c_seg.length
150
+ position_ids[c_seg.start : c_seg.end] = text_start_offset + torch.arange(local_len)
151
+ cursor = text_start_offset + local_len
152
+ for ri, seg in enumerate(s for s in segments if s.kind == "readout" and s.question_idx == qi):
153
+ rlen = seg.length
154
+ position_ids[seg.start : seg.end] = cursor + torch.arange(rlen)
155
+ cursor += rlen
156
+
157
+ sentinel_positions = {
158
+ (seg.question_idx, seg.readout_idx): seg.sentinel_pos
159
+ for seg in segments
160
+ if seg.kind == "readout"
161
+ }
162
+
163
+ return PackedBatch(
164
+ seq_len=seq_len,
165
+ segments=segments,
166
+ attn_bias=attn_bias,
167
+ position_ids=position_ids.unsqueeze(0),
168
+ sentinel_positions=sentinel_positions,
169
+ )
170
+
171
+
172
+ def permute_questions(questions: list[Question], order: list[int]) -> list[Question]:
173
+ return [questions[i] for i in order]