diff --git a/brain-bar/Sources/BrainBar/BrainDatabase.swift b/brain-bar/Sources/BrainBar/BrainDatabase.swift index 750f843d5..133e00b61 100644 --- a/brain-bar/Sources/BrainBar/BrainDatabase.swift +++ b/brain-bar/Sources/BrainBar/BrainDatabase.swift @@ -5516,77 +5516,51 @@ final class BrainDatabase: @unchecked Sendable { func lookupEntity(query: String, entityType: String? = nil) throws -> [String: Any]? { guard let db else { throw DBError.notOpen } - // First try exact name match - let exactSQL = """ - SELECT id, entity_type, name, metadata, description, importance - FROM kg_entities - WHERE name = ? - AND (? IS NULL OR entity_type = ?) - LIMIT 1 - """ - var exactStmt: OpaquePointer? - guard sqlite3_prepare_v2(db, exactSQL, -1, &exactStmt, nil) == SQLITE_OK else { - throw DBError.prepare(sqlite3_errcode(db)) - } - bindText(query, to: exactStmt, index: 1) - if let entityType { - bindText(entityType, to: exactStmt, index: 2) - bindText(entityType, to: exactStmt, index: 3) - } else { - sqlite3_bind_null(exactStmt, 2) - sqlite3_bind_null(exactStmt, 3) - } + // Resolution tiers, first hit wins (#663): + // 1. exact name (case-sensitive) — an exact spelling always takes precedence; + // 2. exact name, ASCII case-insensitive — so "nimbus" resolves "Nimbus" instead + // of falling through to the substring tier and landing on whichever entity + // merely *contains* the query. Ambiguous case variants are ordered + // deterministically (importance, then binary name, then id); + // 3. substring LIKE fallback (unchanged). + // The returned `name` is always the stored canonical spelling, never the query. + let selectColumns = "SELECT id, entity_type, name, metadata, description, importance FROM kg_entities" + let typeFilter = "AND (? IS NULL OR entity_type = ?)" + let tiers: [(sql: String, pattern: String)] = [ + ("\(selectColumns) WHERE name = ? \(typeFilter) LIMIT 1", query), + ("\(selectColumns) WHERE name = ? COLLATE NOCASE \(typeFilter) " + + "ORDER BY importance DESC, name COLLATE BINARY ASC, id ASC LIMIT 1", query), + ("\(selectColumns) WHERE name LIKE ? \(typeFilter) LIMIT 1", "%\(query)%"), + ] var entityId: String? var result: [String: Any]? - if sqlite3_step(exactStmt) == SQLITE_ROW { - entityId = columnText(exactStmt, 0) - result = [ - "entity_id": entityId as Any, - "entity_type": columnText(exactStmt, 1) as Any, - "name": columnText(exactStmt, 2) as Any, - "metadata": columnText(exactStmt, 3) as Any, - "description": columnText(exactStmt, 4) as Any, - "importance": sqlite3_column_double(exactStmt, 5) - ] - } - sqlite3_finalize(exactStmt) - - // If no exact match, try LIKE - if result == nil { - let likeSQL = """ - SELECT id, entity_type, name, metadata, description, importance - FROM kg_entities - WHERE name LIKE ? - AND (? IS NULL OR entity_type = ?) - LIMIT 1 - """ - var likeStmt: OpaquePointer? - guard sqlite3_prepare_v2(db, likeSQL, -1, &likeStmt, nil) == SQLITE_OK else { + for tier in tiers where result == nil { + var stmt: OpaquePointer? + guard sqlite3_prepare_v2(db, tier.sql, -1, &stmt, nil) == SQLITE_OK else { throw DBError.prepare(sqlite3_errcode(db)) } - bindText("%\(query)%", to: likeStmt, index: 1) + defer { sqlite3_finalize(stmt) } + bindText(tier.pattern, to: stmt, index: 1) if let entityType { - bindText(entityType, to: likeStmt, index: 2) - bindText(entityType, to: likeStmt, index: 3) + bindText(entityType, to: stmt, index: 2) + bindText(entityType, to: stmt, index: 3) } else { - sqlite3_bind_null(likeStmt, 2) - sqlite3_bind_null(likeStmt, 3) + sqlite3_bind_null(stmt, 2) + sqlite3_bind_null(stmt, 3) } - - if sqlite3_step(likeStmt) == SQLITE_ROW { - entityId = columnText(likeStmt, 0) + if sqlite3_step(stmt) == SQLITE_ROW { + entityId = columnText(stmt, 0) result = [ "entity_id": entityId as Any, - "entity_type": columnText(likeStmt, 1) as Any, - "name": columnText(likeStmt, 2) as Any, - "metadata": columnText(likeStmt, 3) as Any, - "description": columnText(likeStmt, 4) as Any, - "importance": sqlite3_column_double(likeStmt, 5) + "entity_type": columnText(stmt, 1) as Any, + "name": columnText(stmt, 2) as Any, + "metadata": columnText(stmt, 3) as Any, + "description": columnText(stmt, 4) as Any, + "importance": sqlite3_column_double(stmt, 5) ] } - sqlite3_finalize(likeStmt) } // Get typed relations for found entity (excludes co_occurs_with noise) diff --git a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift index d0aef2657..69ea0644d 100644 --- a/brain-bar/Tests/BrainBarTests/DatabaseTests.swift +++ b/brain-bar/Tests/BrainBarTests/DatabaseTests.swift @@ -1980,6 +1980,66 @@ final class DatabaseTests: XCTestCase { XCTAssertNil(entity) } + // #663: a differently-cased query must resolve the existing entity, not fall + // through to the substring tier and return an unrelated card that merely + // contains the query. Names are synthetic. + private func setEntityImportance(_ id: String, _ importance: Double) throws { + let handle = try XCTUnwrap(db.dbHandle) + let sql = "UPDATE kg_entities SET importance = \(importance) WHERE id = '\(id)'" + XCTAssertEqual(sqlite3_exec(handle, sql, nil, nil, nil), SQLITE_OK) + } + + func testEntityLookupResolvesDifferentCaseToCanonicalEntity() throws { + // Inserted first so a full-scan substring match reaches it before "Nimbus". + try db.insertEntity(id: "ent-notes", type: "topic", name: "Old Nimbus Notes") + try db.insertEntity(id: "ent-nimbus", type: "company", name: "Nimbus") + try db.insertEntity(id: "ent-avery", type: "person", name: "Avery Quill") + try db.insertRelation(sourceId: "ent-avery", targetId: "ent-nimbus", relationType: "works_at") + + for query in ["Nimbus", "nimbus", "NIMBUS", "nImBuS"] { + let entity = try XCTUnwrap(try db.lookupEntity(query: query), "query \(query)") + XCTAssertEqual(entity["entity_id"] as? String, "ent-nimbus", "query \(query)") + XCTAssertEqual(entity["name"] as? String, "Nimbus", "canonical name kept for \(query)") + let relations = entity["relations"] as? [[String: Any]] ?? [] + XCTAssertEqual(relations.first?["target_name"] as? String, "Avery Quill", "query \(query)") + } + // Substring fallback is untouched. + XCTAssertEqual(try db.lookupEntity(query: "old nimbus")?["entity_id"] as? String, "ent-notes") + } + + func testEntityLookupExactCasePrecedesAndCaseVariantsAreDeterministic() throws { + try db.insertEntity(id: "ent-lower", type: "company", name: "Nimbus") + try db.insertEntity(id: "ent-upper", type: "company", name: "NIMBUS") + try setEntityImportance("ent-lower", 0.2) + try setEntityImportance("ent-upper", 0.9) + + // Exact spelling wins regardless of importance. + XCTAssertEqual(try db.lookupEntity(query: "Nimbus")?["entity_id"] as? String, "ent-lower") + XCTAssertEqual(try db.lookupEntity(query: "NIMBUS")?["entity_id"] as? String, "ent-upper") + // No exact spelling: higher importance wins. + XCTAssertEqual(try db.lookupEntity(query: "nimbus")?["entity_id"] as? String, "ent-upper") + // Importance tie: binary name order ("NIMBUS" < "Nimbus"), stable across calls. + try setEntityImportance("ent-upper", 0.2) + for _ in 0..<3 { + XCTAssertEqual(try db.lookupEntity(query: "nimbus")?["entity_id"] as? String, "ent-upper") + } + // Entity-type filter still applies to the case-insensitive tier. + XCTAssertNil(try db.lookupEntity(query: "nimbus", entityType: "person")) + } + + func testEntityLookupMissingDifferentCaseDoesNotCreateEntity() throws { + try db.insertEntity(id: "ent-nimbus", type: "company", name: "Nimbus") + XCTAssertNil(try db.lookupEntity(query: "nimbuz")) + XCTAssertNil(try db.lookupEntity(query: "NIMBUZ")) + + let handle = try XCTUnwrap(db.dbHandle) + var stmt: OpaquePointer? + XCTAssertEqual(sqlite3_prepare_v2(handle, "SELECT COUNT(*) FROM kg_entities", -1, &stmt, nil), SQLITE_OK) + defer { sqlite3_finalize(stmt) } + XCTAssertEqual(sqlite3_step(stmt), SQLITE_ROW) + XCTAssertEqual(sqlite3_column_int(stmt, 0), 1, "lookup must never create entities") + } + // MARK: - brain_recall (session context) func testRecallStatsMode() throws {