package db import ( "database/sql" "fmt" "sort" ) // Solve runs the v0 algorithm (design v4.1 §5b.2) against the project. // If preview is true, no DB writes happen — the function returns the // diff it WOULD apply. If preview is false, the diff is applied in a // single transaction. // // Algorithm: // 1. Read all auto cables, manual cables, ports, requirements. // 2. Reserve ports used by manual cables (auto=0) so the solver // doesn't reuse them. // 3. For each requirement (must_connect DESC, id ASC): // - Resolve cable type: preferred, or T = port-types(from) ∩ // port-types(to). |T|==1 → that. |T|>1 → unsatisfied (ambiguous). // |T|==0 → unsatisfied (no compat type). // - Find lowest-id free port on each side. None → unsatisfied // (no free port). Reserve both. // - Stage an "add cable {from_port, to_port, type, auto=1}". // 4. Endpoint-pair bundle: any pair of device endpoints with ≥ 2 // staged cables becomes an auto bundle. // 5. Diff against existing auto cables/bundles: removed = existing // auto rows not in the staged set; kept = those that match by // (from_port, to_port, type); add = remaining staged rows. func (s *Store) Solve(projectID int64, preview bool) (*SolveResult, error) { res := &SolveResult{ CablesAdded: []Cable{}, CablesKept: []int64{}, CablesRemoved: []int64{}, BundlesAdded: []Bundle{}, BundlesRemoved: []int64{}, Unsatisfied: []UnsatisfiedReq{}, Warnings: []string{}, } if _, err := s.GetProject(projectID); err != nil { return nil, err } devices, err := s.ListDevices(projectID, nil) if err != nil { return nil, err } ports, err := s.ListPortsForProject(projectID) if err != nil { return nil, err } cables, err := s.ListCables(projectID) if err != nil { return nil, err } reqs, err := s.ListConnectionRequirements(projectID) if err != nil { return nil, err } bundles, err := s.ListBundles(projectID) if err != nil { return nil, err } // Index ports by (device_id, type_id), sorted by id (deterministic). portsByDevice := map[int64][]Port{} for _, p := range ports { portsByDevice[p.DeviceID] = append(portsByDevice[p.DeviceID], p) } for did := range portsByDevice { sort.SliceStable(portsByDevice[did], func(i, j int) bool { return portsByDevice[did][i].ID < portsByDevice[did][j].ID }) } deviceByID := map[int64]Device{} for _, d := range devices { deviceByID[d.ID] = d } // Reserve ports used by manual cables. usedPorts := map[int64]bool{} autoCablesByID := map[int64]Cable{} for _, c := range cables { if c.Auto { autoCablesByID[c.ID] = c continue } if c.FromPortID != nil { usedPorts[*c.FromPortID] = true } if c.ToPortID != nil { usedPorts[*c.ToPortID] = true } } // Sort requirements: must_connect DESC, id ASC. rs := append([]ConnectionRequirement{}, reqs...) sort.SliceStable(rs, func(i, j int) bool { if rs[i].MustConnect != rs[j].MustConnect { return rs[i].MustConnect } return rs[i].ID < rs[j].ID }) type staged struct { typeID int64 fromPortID int64 toPortID int64 fromDeviceID int64 toDeviceID int64 } var staging []staged for _, r := range rs { _, fromOK := deviceByID[r.FromDeviceID] _, toOK := deviceByID[r.ToDeviceID] if !fromOK || !toOK { // Shouldn't happen (FK CASCADE removes the row when a device // goes), but be defensive. continue } // Resolve cable type. var typeID int64 if r.PreferredCableTypeID != nil { typeID = *r.PreferredCableTypeID } else { fromTypes := map[int64]bool{} for _, p := range portsByDevice[r.FromDeviceID] { fromTypes[p.TypeID] = true } candidates := []int64{} for _, p := range portsByDevice[r.ToDeviceID] { if fromTypes[p.TypeID] { // Add unique. already := false for _, c := range candidates { if c == p.TypeID { already = true break } } if !already { candidates = append(candidates, p.TypeID) } } } if len(candidates) == 0 { if r.MustConnect { res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{ RequirementID: r.ID, Reason: "no compatible cable type — devices share no port-type", }) } continue } if len(candidates) > 1 { if r.MustConnect { res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{ RequirementID: r.ID, Reason: "ambiguous cable type — specify preferred_cable_type_id", }) } continue } typeID = candidates[0] } // Pick lowest-id free port of `typeID` on each side. pickFree := func(deviceID, t int64) *int64 { for _, p := range portsByDevice[deviceID] { if p.TypeID != t { continue } if usedPorts[p.ID] { continue } return &p.ID } return nil } fromPort := pickFree(r.FromDeviceID, typeID) toPort := pickFree(r.ToDeviceID, typeID) if fromPort == nil || toPort == nil { if r.MustConnect { side := "" if fromPort == nil && toPort == nil { side = "" } else if fromPort == nil { side = "from" } else { side = "to" } typeName := "" if ct, err := s.GetCableType(typeID); err == nil { typeName = ct.Name } res.Unsatisfied = append(res.Unsatisfied, UnsatisfiedReq{ RequirementID: r.ID, Reason: fmt.Sprintf("no free %s port", typeName), WhichSide: side, CableType: typeName, }) } continue } usedPorts[*fromPort] = true usedPorts[*toPort] = true staging = append(staging, staged{ typeID: typeID, fromPortID: *fromPort, toPortID: *toPort, fromDeviceID: r.FromDeviceID, toDeviceID: r.ToDeviceID, }) } // Match staged → existing auto cables by (typeID, fromPortID, toPortID) // or its reverse. Anything matched is "kept"; the rest of auto cables // is "removed". Unmatched staged entries become "added". type sigKey struct{ typeID, a, b int64 } matched := map[int64]bool{} // existing auto cable IDs that match sigToAuto := map[sigKey]int64{} for id, c := range autoCablesByID { if c.FromPortID == nil || c.ToPortID == nil { continue } a, b := *c.FromPortID, *c.ToPortID if a > b { a, b = b, a } sigToAuto[sigKey{c.TypeID, a, b}] = id } var toAdd []staged for _, st := range staging { a, b := st.fromPortID, st.toPortID if a > b { a, b = b, a } if existingID, ok := sigToAuto[sigKey{st.typeID, a, b}]; ok { matched[existingID] = true res.CablesKept = append(res.CablesKept, existingID) continue } toAdd = append(toAdd, st) } for id := range autoCablesByID { if !matched[id] { res.CablesRemoved = append(res.CablesRemoved, id) } } sort.Slice(res.CablesKept, func(i, j int) bool { return res.CablesKept[i] < res.CablesKept[j] }) sort.Slice(res.CablesRemoved, func(i, j int) bool { return res.CablesRemoved[i] < res.CablesRemoved[j] }) // Endpoint-pair bundling for the final set of auto cables (kept + added). // Group by unordered (deviceA, deviceB). Build the map of port_id → device_id // for fast lookup. portToDevice := map[int64]int64{} for _, p := range ports { portToDevice[p.ID] = p.DeviceID } type pairKey struct{ a, b int64 } pairGroup := map[pairKey][]string{} // staged-or-kept tags (we just count) pairOrder := []pairKey{} // first-seen order // We'll need the final list of cables-after-apply (with their IDs) to // build bundles. For preview, kept IDs are real, added IDs are zero; // for apply, we'll re-bundle after inserts. if preview { // In preview mode, "kept" IDs are real cables; "added" are // staged. We still compute bundles_added so the UI can show // which cable groups will be bundled. Bundles_added carry // `CableIDs: []` for the staged entries because they don't // have IDs yet — the UI maps by position. cables_kept that // belong to a bundle group also list their existing ids. // In short, slot every staged cable into the same pair bucket // + the kept cables. for _, st := range staging { da, db := st.fromDeviceID, st.toDeviceID if da > db { da, db = db, da } pk := pairKey{da, db} if _, ok := pairGroup[pk]; !ok { pairOrder = append(pairOrder, pk) } pairGroup[pk] = append(pairGroup[pk], "") } // Materialise preview-shape Cable structs for the added rows. for _, st := range toAdd { c := Cable{ ProjectID: projectID, TypeID: st.typeID, FromPortID: ptr(st.fromPortID), ToPortID: ptr(st.toPortID), Auto: true, } res.CablesAdded = append(res.CablesAdded, c) } for _, pk := range pairOrder { if len(pairGroup[pk]) < 2 { continue } a := deviceByID[pk.a].Name b := deviceByID[pk.b].Name res.BundlesAdded = append(res.BundlesAdded, Bundle{ ProjectID: projectID, Name: a + " ↔ " + b, Auto: true, CableIDs: nil, // post-apply only }) } // Existing auto bundles all "would be removed" since we rebuild // from scratch each solve (slice-6 v0 is wholesale-replace). for _, b := range bundles { if b.Auto { res.BundlesRemoved = append(res.BundlesRemoved, b.ID) } } return res, nil } // Apply mode: open a transaction, delete removed auto cables + auto // bundles, insert added cables, re-bundle by endpoint pair. tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() // Delete obsolete auto bundles (we'll rebuild). if _, err := tx.Exec( `DELETE FROM bundles WHERE project_id = ? AND auto = 1`, projectID, ); err != nil { return nil, err } for _, b := range bundles { if b.Auto { res.BundlesRemoved = append(res.BundlesRemoved, b.ID) } } // Delete removed auto cables. for _, id := range res.CablesRemoved { if _, err := tx.Exec( `DELETE FROM cables WHERE id = ? AND project_id = ?`, id, projectID, ); err != nil { return nil, err } } // Insert added cables. Track new ids by their staged signature for // bundle wiring. type addedRow struct { id int64 staged staged } addedRows := []addedRow{} for _, st := range toAdd { c, err := s.createCable(tx, projectID, CableCreate{ TypeID: st.typeID, From: CableEndpoint{PortID: &st.fromPortID}, To: CableEndpoint{PortID: &st.toPortID}, Auto: true, }) if err != nil { return nil, err } res.CablesAdded = append(res.CablesAdded, *c) addedRows = append(addedRows, addedRow{id: c.ID, staged: st}) } // Re-bundle: all auto cables (kept + added) grouped by endpoint pair. // First, collect cable IDs per (deviceA, deviceB) — both kept (from // matched map) and added. groups := map[pairKey][]int64{} order := []pairKey{} addToGroup := func(da, db, cid int64) { if da > db { da, db = db, da } pk := pairKey{da, db} if _, ok := groups[pk]; !ok { order = append(order, pk) } groups[pk] = append(groups[pk], cid) } for id, c := range autoCablesByID { if !matched[id] { continue } if c.FromPortID == nil || c.ToPortID == nil { continue } da := portToDevice[*c.FromPortID] db := portToDevice[*c.ToPortID] if da == 0 || db == 0 { continue } addToGroup(da, db, id) } for _, ar := range addedRows { addToGroup(ar.staged.fromDeviceID, ar.staged.toDeviceID, ar.id) } for _, pk := range order { ids := groups[pk] if len(ids) < 2 { continue } a := deviceByID[pk.a].Name b := deviceByID[pk.b].Name bundle, err := s.createBundle(tx, projectID, BundleCreate{ Name: a + " ↔ " + b, CableIDs: ids, Auto: true, }, false) if err != nil { return nil, err } res.BundlesAdded = append(res.BundlesAdded, *bundle) } if err := tx.Commit(); err != nil { return nil, err } return res, nil } func ptr[T any](v T) *T { return &v } // PortsAndResolve adds a port to a device + re-runs Solve in one tx. // Used by the inspector's "+ Add port and re-solve" quick-fix. type PortsAndResolveResult struct { Port Port `json:"port"` Solve *SolveResult `json:"solve"` } func (s *Store) PortsAndResolve(projectID, deviceID int64, typeID int64, label string, xOff, yOff float64) (*PortsAndResolveResult, error) { d, err := s.GetDevice(projectID, deviceID) if err != nil { return nil, err } if _, err := s.GetCableType(typeID); err != nil { if err == sql.ErrNoRows { return nil, fmt.Errorf("%w: cable type %d not found", ErrInvalidInput, typeID) } } tx, err := s.db.Begin() if err != nil { return nil, err } defer tx.Rollback() // Default the new port to the bottom edge at the right-most existing offset. if xOff == 0 && yOff == 0 { xOff = d.Width / 2 yOff = d.Height } var labelArg any if label != "" { labelArg = label } res, err := tx.Exec( `INSERT INTO ports (project_id, device_id, type_id, label, x_offset, y_offset) VALUES (?, ?, ?, ?, ?, ?)`, projectID, deviceID, typeID, labelArg, xOff, yOff, ) if err != nil { return nil, mapWriteErr(err) } portID, _ := res.LastInsertId() if err := tx.Commit(); err != nil { return nil, err } // Now re-solve outside the tx — Solve manages its own tx for the // apply path. This is a slight relaxation of "single round-trip" — if // the solver run fails the port stays, but that's fine; the port is // what m wanted regardless. solveRes, err := s.Solve(projectID, false) if err != nil { return nil, err } // Re-fetch the port row to return its full shape. port, err := s.getPortByID(portID) if err != nil { return nil, err } return &PortsAndResolveResult{Port: *port, Solve: solveRes}, nil } func (s *Store) getPortByID(id int64) (*Port, error) { var p Port var label, ex sql.NullString err := s.db.QueryRow( `SELECT id, project_id, device_id, type_id, label, x_offset, y_offset, excalidraw_id, created_at, updated_at FROM ports WHERE id = ?`, id, ).Scan(&p.ID, &p.ProjectID, &p.DeviceID, &p.TypeID, &label, &p.XOffset, &p.YOffset, &ex, &p.CreatedAt, &p.UpdatedAt) if err != nil { return nil, err } if label.Valid { v := label.String p.Label = &v } if ex.Valid { p.ExcalidrawID = &ex.String } return &p, nil }