Skip to content

Commit f52bffa

Browse files
committed
feat(pipeline): PIPELINE-ALPHA — 10-step tri pipeline orchestrator
- Add src/tri/pipeline.zig - PipelineRun: 10-step orchestrator (scan→pick→research→spec→gen→test→verdict→experience→commit→loop) Fail threshold (3 consecutive = stop), progress tracking Issue binding, step recording, shouldContinue logic - ExperienceStore: cross-session experience persistence findSimilar for pattern matching, success rate tracking - ToxicVerdict: before/after scoring with delta threshold Multi-category verdict aggregation, overall pass/fail - Verdict: per-category with before→after delta - 6 tests: step recording, progress, fail threshold, experience save/query, verdict pass, verdict fail Closes #496
1 parent a684270 commit f52bffa

1 file changed

Lines changed: 326 additions & 0 deletions

File tree

src/tri/pipeline.zig

Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
1+
const std = @import("std");
2+
3+
pub const PipelineStep = enum {
4+
scan,
5+
pick,
6+
research,
7+
spec_create,
8+
gen,
9+
test,
10+
verdict,
11+
experience_save,
12+
git_commit,
13+
loop_decide,
14+
};
15+
16+
pub const StepStatus = enum {
17+
pending,
18+
in_progress,
19+
passed,
20+
failed,
21+
skipped,
22+
};
23+
24+
pub const StepResult = struct {
25+
step: PipelineStep,
26+
status: StepStatus,
27+
message: []const u8,
28+
timestamp: u64,
29+
duration_ns: u64,
30+
};
31+
32+
pub const PipelineConfig = struct {
33+
max_iterations: u32 = 10,
34+
fail_threshold: u32 = 3,
35+
toxic_check: bool = true,
36+
auto_commit: bool = true,
37+
};
38+
39+
pub const PipelineRun = struct {
40+
allocator: std.mem.Allocator,
41+
config: PipelineConfig,
42+
steps: std.ArrayList(StepResult),
43+
current_iteration: u32,
44+
issue_number: ?u32,
45+
started_at: u64,
46+
47+
pub fn init(allocator: std.mem.Allocator, config: PipelineConfig) PipelineRun {
48+
return .{
49+
.allocator = allocator,
50+
.config = config,
51+
.steps = std.ArrayList(StepResult).init(allocator),
52+
.current_iteration = 0,
53+
.issue_number = null,
54+
.started_at = @intCast(std.time.milliTimestamp()),
55+
};
56+
}
57+
58+
pub fn deinit(self: *PipelineRun) void {
59+
for (self.steps.items) |s| {
60+
self.allocator.free(s.message);
61+
}
62+
self.steps.deinit();
63+
}
64+
65+
pub fn setIssue(self: *PipelineRun, issue_number: u32) void {
66+
self.issue_number = issue_number;
67+
}
68+
69+
pub fn recordStep(self: *PipelineRun, step: PipelineStep, status: StepStatus, message: []const u8) !void {
70+
const msg_copy = try self.allocator.dupe(u8, message);
71+
try self.steps.append(.{
72+
.step = step,
73+
.status = status,
74+
.message = msg_copy,
75+
.timestamp = @intCast(std.time.milliTimestamp()),
76+
.duration_ns = 0,
77+
});
78+
}
79+
80+
pub fn currentStep(self: *const PipelineRun) ?PipelineStep {
81+
const step_order = [_]PipelineStep{
82+
.scan, .pick, .research, .spec_create,
83+
.gen, .test, .verdict, .experience_save,
84+
.git_commit, .loop_decide,
85+
};
86+
for (step_order) |s| {
87+
var found = false;
88+
for (self.steps.items) |sr| {
89+
if (sr.step == s and sr.status != .skipped) {
90+
found = true;
91+
break;
92+
}
93+
}
94+
if (!found) return s;
95+
}
96+
return null;
97+
}
98+
99+
pub fn completedSteps(self: *const PipelineRun) usize {
100+
var count: usize = 0;
101+
for (self.steps.items) |s| {
102+
if (s.status == .passed) count += 1;
103+
}
104+
return count;
105+
}
106+
107+
pub fn failedSteps(self: *const PipelineRun) usize {
108+
var count: usize = 0;
109+
for (self.steps.items) |s| {
110+
if (s.status == .failed) count += 1;
111+
}
112+
return count;
113+
}
114+
115+
pub fn progress(self: *const PipelineRun) f32 {
116+
return @as(f32, @floatFromInt(self.completedSteps())) / 10.0;
117+
}
118+
119+
pub fn shouldContinue(self: *const PipelineRun) bool {
120+
if (self.failedSteps() >= self.config.fail_threshold) return false;
121+
if (self.current_iteration >= self.config.max_iterations) return false;
122+
return self.currentStep() != null;
123+
}
124+
};
125+
126+
pub const ExperienceEntry = struct {
127+
issue_number: u32,
128+
pattern: []const u8,
129+
approach: []const u8,
130+
mistakes: std.ArrayList([]const u8),
131+
learnings: std.ArrayList([]const u8),
132+
success: bool,
133+
timestamp: u64,
134+
135+
pub fn deinit(self: *ExperienceEntry, allocator: std.mem.Allocator) void {
136+
allocator.free(self.pattern);
137+
allocator.free(self.approach);
138+
for (self.mistakes.items) |m| allocator.free(m);
139+
self.mistakes.deinit();
140+
for (self.learnings.items) |l| allocator.free(l);
141+
self.learnings.deinit();
142+
}
143+
};
144+
145+
pub const ExperienceStore = struct {
146+
allocator: std.mem.Allocator,
147+
entries: std.ArrayList(ExperienceEntry),
148+
149+
pub fn init(allocator: std.mem.Allocator) ExperienceStore {
150+
return .{
151+
.allocator = allocator,
152+
.entries = std.ArrayList(ExperienceEntry).init(allocator),
153+
};
154+
}
155+
156+
pub fn deinit(self: *ExperienceStore) void {
157+
for (self.entries.items) |*e| e.deinit(self.allocator);
158+
self.entries.deinit();
159+
}
160+
161+
pub fn save(self: *ExperienceStore, entry: ExperienceEntry) !void {
162+
try self.entries.append(entry);
163+
}
164+
165+
pub fn findSimilar(self: *const ExperienceStore, pattern: []const u8) ?*const ExperienceEntry {
166+
for (self.entries.items) |*entry| {
167+
if (std.mem.indexOf(u8, entry.pattern, pattern) != null) {
168+
return entry;
169+
}
170+
}
171+
return null;
172+
}
173+
174+
pub fn successRate(self: *const ExperienceStore) f32 {
175+
if (self.entries.items.len == 0) return 0;
176+
var successes: usize = 0;
177+
for (self.entries.items) |e| {
178+
if (e.success) successes += 1;
179+
}
180+
return @as(f32, @floatFromInt(successes)) /
181+
@as(f32, @floatFromInt(self.entries.items.len));
182+
}
183+
};
184+
185+
pub const Verdict = struct {
186+
category: []const u8,
187+
before_score: f32,
188+
after_score: f32,
189+
delta: f32,
190+
passed: bool,
191+
192+
pub fn init(category: []const u8, before: f32, after: f32, threshold: f32) Verdict {
193+
const d = after - before;
194+
return .{
195+
.category = category,
196+
.before_score = before,
197+
.after_score = after,
198+
.delta = d,
199+
.passed = d >= threshold,
200+
};
201+
}
202+
};
203+
204+
pub const ToxicVerdict = struct {
205+
verdicts: std.ArrayList(Verdict),
206+
overall_passed: bool,
207+
208+
pub fn init(allocator: std.mem.Allocator) ToxicVerdict {
209+
return .{
210+
.verdicts = std.ArrayList(Verdict).init(allocator),
211+
.overall_passed = true,
212+
};
213+
}
214+
215+
pub fn deinit(self: *ToxicVerdict) void {
216+
self.verdicts.deinit();
217+
}
218+
219+
pub fn addVerdict(self: *ToxicVerdict, v: Verdict) void {
220+
if (!v.passed) self.overall_passed = false;
221+
self.verdicts.append(v) catch {};
222+
}
223+
224+
pub fn format(self: *const ToxicVerdict, writer: anytype) !void {
225+
try writer.print("Toxic Verdict: ", .{});
226+
if (self.overall_passed) {
227+
try writer.print("PASS", .{});
228+
} else {
229+
try writer.print("FAIL", .{});
230+
}
231+
try writer.print(" ({d}/{d} passed)\n", .{
232+
self.passCount(),
233+
self.verdicts.items.len,
234+
});
235+
for (self.verdicts.items) |v| {
236+
const status = if (v.passed) "OK" else "FAIL";
237+
try writer.print(" {s}: {d:.1} → {d:.1} (Δ={d:+.1}) [{s}]\n", .{
238+
v.category,
239+
v.before_score,
240+
v.after_score,
241+
v.delta,
242+
status,
243+
});
244+
}
245+
}
246+
247+
fn passCount(self: *const ToxicVerdict) usize {
248+
var c: usize = 0;
249+
for (self.verdicts.items) |v| {
250+
if (v.passed) c += 1;
251+
}
252+
return c;
253+
}
254+
};
255+
256+
test "pipeline run records steps" {
257+
const allocator = std.testing.allocator;
258+
var run = PipelineRun.init(allocator, .{});
259+
defer run.deinit();
260+
261+
try run.recordStep(.scan, .passed, "Found 5 similar issues");
262+
try run.recordStep(.pick, .passed, "Selected issue #42");
263+
try std.testing.expectEqual(@as(usize, 2), run.steps.items.len);
264+
try std.testing.expectEqual(@as(usize, 2), run.completedSteps());
265+
}
266+
267+
test "pipeline tracks progress" {
268+
const allocator = std.testing.allocator;
269+
var run = PipelineRun.init(allocator, .{});
270+
defer run.deinit();
271+
272+
try run.recordStep(.scan, .passed, "ok");
273+
try run.recordStep(.pick, .passed, "ok");
274+
275+
try std.testing.expect(run.progress() > 0);
276+
try std.testing.expect(run.shouldContinue());
277+
}
278+
279+
test "pipeline stops after too many failures" {
280+
const allocator = std.testing.allocator;
281+
var run = PipelineRun.init(allocator, .{ .fail_threshold = 2 });
282+
defer run.deinit();
283+
284+
try run.recordStep(.test, .failed, "test failed");
285+
try run.recordStep(.test, .failed, "test failed again");
286+
try std.testing.expect(!run.shouldContinue());
287+
}
288+
289+
test "experience store saves and queries" {
290+
const allocator = std.testing.allocator;
291+
var store = ExperienceStore.init(allocator);
292+
defer store.deinit();
293+
294+
const entry = ExperienceEntry{
295+
.issue_number = 42,
296+
.pattern = try allocator.dupe(u8, "ternary-matmul"),
297+
.approach = try allocator.dupe(u8, "sparse-iteration"),
298+
.mistakes = std.ArrayList([]const u8).init(allocator),
299+
.learnings = std.ArrayList([]const u8).init(allocator),
300+
.success = true,
301+
.timestamp = 1000,
302+
};
303+
try store.save(entry);
304+
try std.testing.expect(store.findSimilar("ternary") != null);
305+
try std.testing.expectEqual(@as(f32, 1.0), store.successRate());
306+
}
307+
308+
test "toxic verdict tracks pass/fail" {
309+
const allocator = std.testing.allocator;
310+
var verdict = ToxicVerdict.init(allocator);
311+
defer verdict.deinit();
312+
313+
verdict.addVerdict(Verdict.init("tests", 3, 7, 3));
314+
verdict.addVerdict(Verdict.init("lint", 5, 7, 3));
315+
try std.testing.expect(verdict.overall_passed);
316+
}
317+
318+
test "toxic verdict detects failure" {
319+
const allocator = std.testing.allocator;
320+
var verdict = ToxicVerdict.init(allocator);
321+
defer verdict.deinit();
322+
323+
verdict.addVerdict(Verdict.init("tests", 3, 7, 3));
324+
verdict.addVerdict(Verdict.init("lint", 5, 4, 3));
325+
try std.testing.expect(!verdict.overall_passed);
326+
}

0 commit comments

Comments
 (0)