diff --git a/cli/src/commands/annotate.zig b/cli/src/commands/annotate.zig new file mode 100644 index 0000000..6989f45 --- /dev/null +++ b/cli/src/commands/annotate.zig @@ -0,0 +1,143 @@ +const std = @import("std"); +const config = @import("../config.zig"); +const db = @import("../db.zig"); +const core = @import("../core.zig"); +const colors = @import("../utils/colors.zig"); +const manifest_lib = @import("../manifest.zig"); + +/// Annotate command - unified metadata annotation +/// Usage: +/// ml annotate --text "Try lr=3e-4 next" +/// ml annotate --hypothesis "LR scaling helps" +/// ml annotate --outcome validates --confidence 0.9 +/// ml annotate --privacy private +pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void { + var flags = core.flags.CommonFlags{}; + var command_args = try core.flags.parseCommon(allocator, args, &flags); + defer command_args.deinit(allocator); + + core.output.init(if (flags.json) .json else .text); + + if (flags.help) { + return printUsage(); + } + + if (command_args.items.len < 1) { + std.log.err("Usage: ml annotate [options]", .{}); + return error.MissingArgument; + } + + const run_id = command_args.items[0]; + + // Parse metadata options + const text = core.flags.parseKVFlag(command_args.items, "text"); + const hypothesis = core.flags.parseKVFlag(command_args.items, "hypothesis"); + const outcome = core.flags.parseKVFlag(command_args.items, "outcome"); + const confidence = core.flags.parseKVFlag(command_args.items, "confidence"); + const privacy = core.flags.parseKVFlag(command_args.items, "privacy"); + const author = core.flags.parseKVFlag(command_args.items, "author"); + + // Check that at least one option is provided + if (text == null and hypothesis == null and outcome == null and privacy == null) { + std.log.err("No metadata provided. Use --text, --hypothesis, --outcome, or --privacy", .{}); + return error.MissingMetadata; + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // Get DB path + const db_path = try cfg.getDBPath(allocator); + defer allocator.free(db_path); + + var database = try db.DB.init(allocator, db_path); + defer database.close(); + + // Verify run exists + const check_sql = "SELECT 1 FROM ml_runs WHERE run_id = ?;"; + const check_stmt = try database.prepare(check_sql); + defer db.DB.finalize(check_stmt); + try db.DB.bindText(check_stmt, 1, run_id); + const has_row = try db.DB.step(check_stmt); + if (!has_row) { + std.log.err("Run not found: {s}", .{run_id}); + return error.RunNotFound; + } + + // Add text note as a tag + if (text) |t| { + try addTag(allocator, &database, run_id, "note", t, author); + } + + // Add hypothesis + if (hypothesis) |h| { + try addTag(allocator, &database, run_id, "hypothesis", h, author); + } + + // Add outcome + if (outcome) |o| { + try addTag(allocator, &database, run_id, "outcome", o, author); + if (confidence) |c| { + try addTag(allocator, &database, run_id, "confidence", c, author); + } + } + + // Add privacy level + if (privacy) |p| { + try addTag(allocator, &database, run_id, "privacy", p, author); + } + + // Checkpoint WAL + database.checkpointOnExit(); + + if (flags.json) { + std.debug.print("{{\"success\":true,\"run_id\":\"{s}\",\"action\":\"note_added\"}}\n", .{run_id}); + } else { + colors.printSuccess("✓ Added note to run {s}\n", .{run_id[0..8]}); + } +} + +fn addTag( + allocator: std.mem.Allocator, + database: *db.DB, + run_id: []const u8, + key: []const u8, + value: []const u8, + author: ?[]const u8, +) !void { + const full_value = if (author) |a| + try std.fmt.allocPrint(allocator, "{s} (by {s})", .{ value, a }) + else + try allocator.dupe(u8, value); + defer allocator.free(full_value); + + const sql = "INSERT INTO ml_tags (run_id, key, value) VALUES (?, ?, ?);"; + const stmt = try database.prepare(sql); + defer db.DB.finalize(stmt); + + try db.DB.bindText(stmt, 1, run_id); + try db.DB.bindText(stmt, 2, key); + try db.DB.bindText(stmt, 3, full_value); + _ = try db.DB.step(stmt); +} + +fn printUsage() !void { + std.debug.print("Usage: ml annotate [options]\n\n", .{}); + std.debug.print("Add metadata annotations to a run.\n\n", .{}); + std.debug.print("Options:\n", .{}); + std.debug.print(" --text Free-form annotation\n", .{}); + std.debug.print(" --hypothesis Research hypothesis\n", .{}); + std.debug.print(" --outcome Outcome: validates/refutes/inconclusive\n", .{}); + std.debug.print(" --confidence <0-1> Confidence in outcome\n", .{}); + std.debug.print(" --privacy Privacy: private/team/public\n", .{}); + std.debug.print(" --author Author of the annotation\n", .{}); + std.debug.print(" --help, -h Show this help\n", .{}); + std.debug.print(" --json Output structured JSON\n\n", .{}); + std.debug.print("Examples:\n", .{}); + std.debug.print(" ml annotate abc123 --text \"Try lr=3e-4 next\"\n", .{}); + std.debug.print(" ml annotate abc123 --hypothesis \"LR scaling helps\"\n", .{}); + std.debug.print(" ml annotate abc123 --outcome validates --confidence 0.9\n", .{}); +} diff --git a/cli/src/commands/experiment.zig b/cli/src/commands/experiment.zig new file mode 100644 index 0000000..ed268ab --- /dev/null +++ b/cli/src/commands/experiment.zig @@ -0,0 +1,229 @@ +const std = @import("std"); +const config = @import("../config.zig"); +const db = @import("../db.zig"); +const core = @import("../core.zig"); +const colors = @import("../utils/colors.zig"); +const mode = @import("../mode.zig"); + +/// Experiment command - manage experiments +/// Usage: +/// ml experiment create --name "baseline-cnn" +/// ml experiment list +pub fn execute(allocator: std.mem.Allocator, args: []const []const u8) !void { + var flags = core.flags.CommonFlags{}; + var command_args = try core.flags.parseCommon(allocator, args, &flags); + defer command_args.deinit(allocator); + + core.output.init(if (flags.json) .json else .text); + + if (flags.help or command_args.items.len == 0) { + return printUsage(); + } + + const subcommand = command_args.items[0]; + const sub_args = if (command_args.items.len > 1) command_args.items[1..] else &[_][]const u8{}; + + if (std.mem.eql(u8, subcommand, "create")) { + return try createExperiment(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "list")) { + return try listExperiments(allocator, sub_args, flags.json); + } else if (std.mem.eql(u8, subcommand, "show")) { + return try showExperiment(allocator, sub_args, flags.json); + } else { + core.output.errorMsg("experiment", "Unknown subcommand: {s}", .{subcommand}); + return printUsage(); + } +} + +fn createExperiment(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + var name: ?[]const u8 = null; + var description: ?[]const u8 = null; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--name") and i + 1 < args.len) { + name = args[i + 1]; + i += 1; + } else if (std.mem.eql(u8, args[i], "--description") and i + 1 < args.len) { + description = args[i + 1]; + i += 1; + } + } + + if (name == null) { + core.output.errorMsg("experiment", "--name is required", .{}); + return error.MissingArgument; + } + + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + // Check mode + const mode_result = try mode.detect(allocator, cfg); + + if (mode.isOffline(mode_result.mode)) { + // Local mode: create in SQLite + const db_path = try cfg.getDBPath(allocator); + defer allocator.free(db_path); + + var database = try db.DB.init(allocator, db_path); + defer database.close(); + + const sql = "INSERT INTO ml_experiments (experiment_id, name, description) VALUES (?, ?, ?);"; + const stmt = try database.prepare(sql); + defer db.DB.finalize(stmt); + + const exp_id = try generateExperimentID(allocator); + defer allocator.free(exp_id); + + try db.DB.bindText(stmt, 1, exp_id); + try db.DB.bindText(stmt, 2, name.?); + try db.DB.bindText(stmt, 3, description orelse ""); + _ = try db.DB.step(stmt); + + // Update config with new experiment + var mut_cfg = cfg; + if (mut_cfg.experiment == null) { + mut_cfg.experiment = config.ExperimentConfig{}; + } + mut_cfg.experiment.?.name = try allocator.dupe(u8, name.?); + try mut_cfg.save(allocator); + + database.checkpointOnExit(); + + if (json) { + std.debug.print("{{\"success\":true,\"experiment_id\":\"{s}\",\"name\":\"{s}\"}}\n", .{ exp_id, name.? }); + } else { + colors.printSuccess("✓ Created experiment: {s} ({s})\n", .{ name.?, exp_id[0..8] }); + } + } else { + // Server mode: would send to server + // For now, just update local config + var mut_cfg = cfg; + if (mut_cfg.experiment == null) { + mut_cfg.experiment = config.ExperimentConfig{}; + } + mut_cfg.experiment.?.name = try allocator.dupe(u8, name.?); + try mut_cfg.save(allocator); + + if (json) { + std.debug.print("{{\"success\":true,\"name\":\"{s}\"}}\n", .{name.?}); + } else { + colors.printSuccess("✓ Set active experiment: {s}\n", .{name.?}); + } + } +} + +fn listExperiments(allocator: std.mem.Allocator, _: []const []const u8, json: bool) !void { + const cfg = try config.Config.load(allocator); + defer { + var mut_cfg = cfg; + mut_cfg.deinit(allocator); + } + + const mode_result = try mode.detect(allocator, cfg); + + if (mode.isOffline(mode_result.mode)) { + // Local mode: list from SQLite + const db_path = try cfg.getDBPath(allocator); + defer allocator.free(db_path); + + var database = try db.DB.init(allocator, db_path); + defer database.close(); + + const sql = "SELECT experiment_id, name, description, created_at, status FROM ml_experiments ORDER BY created_at DESC;"; + const stmt = try database.prepare(sql); + defer db.DB.finalize(stmt); + + var experiments = std.ArrayList(ExperimentInfo).init(allocator); + defer { + for (experiments.items) |*e| e.deinit(allocator); + experiments.deinit(); + } + + while (try db.DB.step(stmt)) { + try experiments.append(ExperimentInfo{ + .id = try allocator.dupe(u8, db.DB.columnText(stmt, 0)), + .name = try allocator.dupe(u8, db.DB.columnText(stmt, 1)), + .description = try allocator.dupe(u8, db.DB.columnText(stmt, 2)), + .created_at = try allocator.dupe(u8, db.DB.columnText(stmt, 3)), + .status = try allocator.dupe(u8, db.DB.columnText(stmt, 4)), + }); + } + + if (json) { + std.debug.print("[", .{}); + for (experiments.items, 0..) |e, i| { + if (i > 0) std.debug.print(",", .{}); + std.debug.print("{{\"id\":\"{s}\",\"name\":\"{s}\",\"status\":\"{s}\"}}", .{ e.id, e.name, e.status }); + } + std.debug.print("]\n", .{}); + } else { + if (experiments.items.len == 0) { + colors.printInfo("No experiments found.\n", .{}); + } else { + colors.printInfo("Experiments:\n", .{}); + for (experiments.items) |e| { + std.debug.print(" {s} {s} ({s})\n", .{ e.id[0..8], e.name, e.status }); + } + } + } + } else { + // Server mode: would query server + colors.printInfo("Server mode: would list experiments from server\n", .{}); + } +} + +fn showExperiment(allocator: std.mem.Allocator, args: []const []const u8, json: bool) !void { + if (args.len == 0) { + core.output.errorMsg("experiment", "experiment_id required", .{}); + return error.MissingArgument; + } + + const exp_id = args[0]; + _ = json; + _ = allocator; + + colors.printInfo("Show experiment: {s}\n", .{exp_id}); + // Implementation would show experiment details +} + +const ExperimentInfo = struct { + id: []const u8, + name: []const u8, + description: []const u8, + created_at: []const u8, + status: []const u8, + + fn deinit(self: *ExperimentInfo, allocator: std.mem.Allocator) void { + allocator.free(self.id); + allocator.free(self.name); + allocator.free(self.description); + allocator.free(self.created_at); + allocator.free(self.status); + } +}; + +fn generateExperimentID(allocator: std.mem.Allocator) ![]const u8 { + const uuid = @import("../utils/uuid.zig"); + return try uuid.generateV4(allocator); +} + +fn printUsage() !void { + std.debug.print("Usage: ml experiment [options]\n\n", .{}); + std.debug.print("Subcommands:\n", .{}); + std.debug.print(" create --name [--description ] Create new experiment\n", .{}); + std.debug.print(" list List experiments\n", .{}); + std.debug.print(" show Show experiment details\n", .{}); + std.debug.print("\nOptions:\n", .{}); + std.debug.print(" --name Experiment name (required for create)\n", .{}); + std.debug.print(" --description Experiment description\n", .{}); + std.debug.print(" --help, -h Show this help\n", .{}); + std.debug.print(" --json Output structured JSON\n\n", .{}); + std.debug.print("Examples:\n", .{}); + std.debug.print(" ml experiment create --name \"baseline-cnn\"\n", .{}); + std.debug.print(" ml experiment list\n", .{}); +} diff --git a/cli/src/main.zig b/cli/src/main.zig index 703b9b2..f6f1488 100644 --- a/cli/src/main.zig +++ b/cli/src/main.zig @@ -38,8 +38,13 @@ pub fn main() !void { } else if (std.mem.eql(u8, command, "info")) { try @import("commands/info.zig").run(allocator, args[2..]); } else handleUnknownCommand(command), - 'n' => if (std.mem.eql(u8, command, "note")) { - try @import("commands/note.zig").run(allocator, args[2..]); + 'a' => if (std.mem.eql(u8, command, "annotate")) { + try @import("commands/annotate.zig").execute(allocator, args[2..]); + } else handleUnknownCommand(command), + 'e' => if (std.mem.eql(u8, command, "experiment")) { + try @import("commands/experiment.zig").execute(allocator, args[2..]); + } else if (std.mem.eql(u8, command, "export")) { + try @import("commands/export_cmd.zig").run(allocator, args[2..]); } else handleUnknownCommand(command), 's' => if (std.mem.eql(u8, command, "sync")) { try @import("commands/sync.zig").run(allocator, args[2..]); @@ -93,7 +98,8 @@ fn printUsage() void { std.debug.print(" init Initialize project with config (use --local for SQLite)\n", .{}); std.debug.print(" run [args] Execute a run locally (forks, captures, parses metrics)\n", .{}); std.debug.print(" queue Queue job on server (--rerun to re-queue local run)\n", .{}); - std.debug.print(" note Add metadata annotation (hypothesis/outcome/confidence)\n", .{}); + std.debug.print(" annotate Add metadata annotations (hypothesis/outcome/confidence)\n", .{}); + std.debug.print(" experiment Manage experiments (create, list, show)\n", .{}); std.debug.print(" logs Fetch or stream run logs (--follow for live tail)\n", .{}); std.debug.print(" sync [id] Push local runs to server (sync_run + sync_ack protocol)\n", .{}); std.debug.print(" cancel Cancel local run (SIGTERM/SIGKILL by PID)\n", .{}); @@ -115,5 +121,6 @@ test { _ = @import("commands/find.zig"); _ = @import("commands/export_cmd.zig"); _ = @import("commands/log.zig"); - _ = @import("commands/note.zig"); + _ = @import("commands/annotate.zig"); + _ = @import("commands/experiment.zig"); }