diff --git a/Package.swift b/Package.swift index 424ceb9..799976b 100644 --- a/Package.swift +++ b/Package.swift @@ -54,6 +54,10 @@ let package = Package( .executableTarget(name: "BookmarkSortCheck", dependencies: ["BookmarkSort", "FluidUse"]), .executableTarget(name: "BookmarkSortDemo", dependencies: ["BookmarkSort"]), .executableTarget(name: "AudioSearchCheck", dependencies: ["FluidUse"]), + .target(name: "CodeSearch"), + .executableTarget(name: "CodeSearchCheck", dependencies: ["CodeSearch", "FluidUse"]), + .executableTarget( + name: "CodeSearchDemo", dependencies: ["CodeSearch", "FluidUse"], exclude: ["README.md", "demo.sh"]), .executableTarget(name: "AudioSearchDemo", dependencies: ["FluidUse"], exclude: ["README.md", "demo.sh"]), .executableTarget( name: "TopicSortDemo", dependencies: ["BookmarkSort", "FluidUse"], exclude: ["README.md", "demo.sh"], @@ -81,6 +85,7 @@ let package = Package( ), .testTarget(name: "LayaTetrisTests", dependencies: ["LayaTetris"]), .testTarget(name: "BookmarkSortTests", dependencies: ["BookmarkSort"]), + .testTarget(name: "CodeSearchTests", dependencies: ["CodeSearch"]), .testTarget(name: "LayaTetrisDemoTests", dependencies: ["LayaTetrisDemo", "LayaTetris"]), .testTarget(name: "Game2048Tests", dependencies: ["Game2048"]), .testTarget(name: "GLiClass2048DemoTests", dependencies: ["GLiClass2048Demo", "Game2048"]), diff --git a/Sources/CodeSearch/CodeChunker.swift b/Sources/CodeSearch/CodeChunker.swift new file mode 100644 index 0000000..a453e45 --- /dev/null +++ b/Sources/CodeSearch/CodeChunker.swift @@ -0,0 +1,102 @@ +import Foundation + +/// One searchable piece of code: a function, initializer, or type declaration with its doc comment. +public struct CodeChunk: Sendable, Hashable { + /// Path relative to the repository root. + public let path: String + /// 1-based line of the declaration. + public let line: Int + /// `Type.member`, or the type's own name. + public let name: String + public let kind: String + /// Doc comment and code, up to `CodeChunker.maxLines` lines. + public let code: String + + /// The document text EmbeddingGemma 2 was trained on for code retrieval: `title: | text: `. + public var document: String { "title: \((path as NSString).lastPathComponent) \(name) | text: \(code)" } +} + +/// Splits Swift sources into declaration chunks with a line scanner (no compiler): each `func`, `init`, +/// `subscript`, or type declaration starts a chunk that runs, doc comment first, to the next declaration. +public enum CodeChunker { + public static let maxLines = 40 + + private static let declaration = try! NSRegularExpression( + pattern: + #"^(\s*)(?:@[A-Za-z_]+(?:\([^)]*\))?\s+)*(?:(?:public|private|internal|fileprivate|open|static|final|override|mutating|nonmutating|nonisolated|convenience|required|indirect|package|class|distributed)\s+|(?:private|fileprivate|internal|public)\(set\)\s+)*(func|init|deinit|subscript|struct|class|enum|actor|protocol|extension)\b\s*([A-Za-z_][A-Za-z0-9_.]*)?"# + ) + private static let typeKinds: Set = ["struct", "class", "enum", "actor", "protocol", "extension"] + private static let skippedDirectories: Set = [".build", ".git", ".swiftpm", "Packages", "DerivedData"] + + /// Chunks of every `.swift` file under `repository`, in path order. + public static func chunks(repository: URL) -> [CodeChunk] { + let root = repository.standardizedFileURL + guard + let walker = FileManager.default.enumerator( + at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) + else { return [] } + var files: [URL] = [] + for case let url as URL in walker { + if skippedDirectories.contains(url.lastPathComponent) { + walker.skipDescendants() + continue + } + if url.pathExtension == "swift" { files.append(url) } + } + let prefix = root.path.hasSuffix("/") ? root.path : root.path + "/" + return files.sorted { $0.path < $1.path }.flatMap { file -> [CodeChunk] in + guard let source = try? String(contentsOf: file, encoding: .utf8) else { return [] } + let path = file.standardizedFileURL.path.replacingOccurrences(of: prefix, with: "") + return chunks(source: source, path: path) + } + } + + /// Chunks of one file's source. + public static func chunks(source: String, path: String) -> [CodeChunk] { + let lines = source.components(separatedBy: "\n") + // (line index, indent, kind, name) + var declarations: [(index: Int, indent: Int, kind: String, name: String)] = [] + for (index, line) in lines.enumerated() { + let range = NSRange(line.startIndex..., in: line) + guard let match = declaration.firstMatch(in: line, range: range), + let kindRange = Range(match.range(at: 2), in: line) + else { continue } + let kind = String(line[kindRange]) + // `class func` / `class var` are members, not types. + if kind == "class", + line.range(of: #"\bclass\s+(func|var|let|subscript)\b"#, options: .regularExpression) != nil + { + continue + } + let indent = Range(match.range(at: 1), in: line).map { line[$0].count } ?? 0 + var name = Range(match.range(at: 3), in: line).map { String(line[$0]) } ?? kind + if kind == "init" || kind == "deinit" || kind == "subscript" { name = kind } + declarations.append((index, indent, kind, name)) + } + var result: [CodeChunk] = [] + var types: [(indent: Int, name: String)] = [] + for (position, item) in declarations.enumerated() { + while let last = types.last, last.indent >= item.indent { types.removeLast() } + let owner = types.last?.name + if typeKinds.contains(item.kind) { types.append((item.indent, item.name)) } + // Doc comment and attributes directly above the declaration. + var first = item.index + while first > 0 { + let above = lines[first - 1].trimmingCharacters(in: .whitespaces) + guard above.hasPrefix("///") || above.hasPrefix("@") || above.hasPrefix("*") || above.hasPrefix("/**") + else { break } + first -= 1 + } + let next = position + 1 < declarations.count ? declarations[position + 1].index : lines.count + var last = max(item.index, next - 1) + // Leave the next declaration's doc comment to it. + while last > item.index, lines[last].trimmingCharacters(in: .whitespaces).hasPrefix("///") { last -= 1 } + let body = lines[first...min(last, first + maxLines - 1)] + let code = body.map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + .joined(separator: "\n") + let name = typeKinds.contains(item.kind) || owner == nil ? item.name : "\(owner!).\(item.name)" + result.append(CodeChunk(path: path, line: item.index + 1, name: name, kind: item.kind, code: code)) + } + return result + } +} diff --git a/Sources/CodeSearchCheck/main.swift b/Sources/CodeSearchCheck/main.swift new file mode 100644 index 0000000..4f2e018 --- /dev/null +++ b/Sources/CodeSearchCheck/main.swift @@ -0,0 +1,115 @@ +import CodeSearch +import Foundation +import FluidUse + +/// Indexes a repository's Swift declarations with EmbeddingGemma 2 and searches them in plain English. +/// +/// swift run -c release CodeSearchCheck [--repo=path] [--tokens=64,128,256] [--eval] [--query="…"]… +/// +/// `--eval` scores FluidAudio questions whose answer is known (top-1 / top-5 by file and name). +@main +struct CodeSearchCheck { + struct Question { + let text: String + let file: String + let name: String? + } + + /// Questions about FluidAudio, worded without the function's own name. + static let questions = [ + Question( + text: "resample an audio file to 16 kHz mono", file: "AudioConverter.swift", name: "resampleAudioFile"), + Question( + text: "download every file of a Hugging Face model repository", file: "DownloadUtils.swift", + name: "downloadRepo"), + Question(text: "variational Bayes clustering of speaker embeddings", file: "VBxClustering.swift", name: nil), + Question(text: "softmax over an array of floats", file: "VDSPOperations.swift", name: "softmax"), + Question( + text: "merge the transcripts of overlapping audio chunks", file: "ChunkProcessor.swift", name: "mergeChunks" + ), + Question( + text: "cosine distance between two speaker embeddings", file: "SpeakerOperations.swift", + name: "cosineDistance"), + Question( + text: "edit distance between two sequences", file: "LevenshteinDistance.swift", name: "levenshteinDistance"), + Question(text: "write float samples to a WAV file", file: "AudioConverter.swift", name: "AudioWAV"), + Question(text: "agglomerative hierarchical clustering", file: "AHCClustering.swift", name: nil), + Question(text: "convert a word into phonemes", file: "G2PModel.swift", name: "phonemize"), + Question( + text: "text to speech with the Kokoro model on the Neural Engine", file: "KokoroAneManager.swift", + name: "synthesize"), + Question( + text: "greedy transducer decoding that also returns token timestamps", file: "AsrManager.swift", + name: "tdtDecodeWithTimings"), + Question(text: "k-means clustering", file: "KMeansClustering.swift", name: nil), + Question( + text: "stretch a weight curve to a new length with interpolation", file: "WeightInterpolation.swift", + name: "resample"), + Question( + text: "load the reference recording used to clone a voice", file: "PocketTtsVoiceCloner.swift", + name: "loadAudio"), + Question( + text: "split a phoneme string into tokens for SSML", file: "SSMLProcessor.swift", name: "tokenizePhonemes"), + ] + + static func main() async throws { + let arguments = CommandLine.arguments.dropFirst() + func value(_ name: String) -> String? { + arguments.first { $0.hasPrefix("--\(name)=") }.map { String($0.dropFirst(name.count + 3)) } + } + let repo = ((value("repo") ?? "~/Documents/FluidAudio") as NSString).expandingTildeInPath + let caps = (value("tokens") ?? "128").split(separator: ",").compactMap { Int($0) } + let queries = arguments.filter { $0.hasPrefix("--query=") }.map { String($0.dropFirst(8)) } + var start = DispatchTime.now().uptimeNanoseconds + let chunks = CodeChunker.chunks(repository: URL(fileURLWithPath: repo)) + print( + String( + format: "%d chunks from %d files in %.2f s", chunks.count, Set(chunks.map(\.path)).count, + seconds(since: start))) + let manager = try await EmbeddingGemma2Manager.loadDefault() + _ = try await manager.embed(Array(repeating: "warm up", count: 16), prompt: .none) + for cap in caps { + start = DispatchTime.now().uptimeNanoseconds + var vectors: [[Float]] = [] + for batch in stride(from: 0, to: chunks.count, by: 256) { + let slice = chunks[batch.. [CodeChunk] { + let q = try await manager.embed(query, prompt: .codeRetrieval) + let scored = vectors.indices.map { ($0, zip(q, vectors[$0]).reduce(Float(0)) { $0 + $1.0 * $1.1 }) } + return scored.sorted { $0.1 > $1.1 }.prefix(k).map { chunks[$0.0] } + } + if arguments.contains("--eval") { + var hit1 = 0 + var hit5 = 0 + for question in questions { + let found = try await top(question.text, 5) + let match = { (chunk: CodeChunk) in + chunk.path.hasSuffix(question.file) + && (question.name.map { chunk.name.hasSuffix($0) || chunk.name.contains($0) } ?? true) + } + if let first = found.first, match(first) { hit1 += 1 } + if found.contains(where: match) { hit5 += 1 } + if !arguments.contains("--quiet") { + print( + " \(found.first.map(match) == true ? "✓" : found.contains(where: match) ? "~" : "✗") \(question.text) → \(found.first.map { "\($0.name) (\(($0.path as NSString).lastPathComponent):\($0.line))" } ?? "-")" + ) + } + } + print(" top-1 \(hit1)/\(questions.count), top-5 \(hit5)/\(questions.count)") + } + for query in queries { + print("\(query):") + for chunk in try await top(query, 3) { print(" \(chunk.name) \(chunk.path):\(chunk.line)") } + } + } + } + + static func seconds(since start: UInt64) -> Double { Double(DispatchTime.now().uptimeNanoseconds - start) / 1e9 } +} diff --git a/Sources/CodeSearchDemo/CodeSearchDemoApp.swift b/Sources/CodeSearchDemo/CodeSearchDemoApp.swift new file mode 100644 index 0000000..558c909 --- /dev/null +++ b/Sources/CodeSearchDemo/CodeSearchDemoApp.swift @@ -0,0 +1,24 @@ +import AppKit +import SwiftUI + +@main +struct CodeSearchDemoApp: App { + @StateObject private var model = CodeSearchModel() + + init() { + setvbuf(stdout, nil, _IOLBF, 0) + // Bare SwiftPM executables start as background processes; make this one a regular windowed app. + NSApplication.shared.setActivationPolicy(.regular) + NSApplication.shared.activate(ignoringOtherApps: true) + } + + var body: some Scene { + WindowGroup("Code search — on-device") { + ContentView() + .environmentObject(model) + .frame(minWidth: 1000, minHeight: 700) + .onAppear { model.start() } + } + .defaultSize(width: 1200, height: 950) + } +} diff --git a/Sources/CodeSearchDemo/CodeSearchModel.swift b/Sources/CodeSearchDemo/CodeSearchModel.swift new file mode 100644 index 0000000..a08bcf9 --- /dev/null +++ b/Sources/CodeSearchDemo/CodeSearchModel.swift @@ -0,0 +1,511 @@ +import Accelerate +import CodeSearch +import FluidUse +import Foundation +import SwiftUI + +/// Indexes a repository's Swift declarations with EmbeddingGemma 2 (Neural Engine) and searches them in plain English. +/// Play runs the show once: index, eight example questions with their top three, then searches back to back as fast +/// as they go. Pause holds it anywhere (timers included); Replay starts over from an empty index. +@MainActor +final class CodeSearchModel: ObservableObject { + enum Step: Equatable { + case loading(String) + case idle + case indexing + case examples + case speed + case finished + case failed(String) + } + + /// A line in the live list while indexing (a function just indexed) or during search speed (question → answer). + struct FeedItem: Identifiable { + let id: Int + let question: String? + let chunk: CodeChunk + } + + struct Result: Identifiable { + let chunk: CodeChunk + let score: Float + var id: String { "\(chunk.path):\(chunk.line)" } + } + + @Published private(set) var step: Step = .loading("Starting…") + @Published private(set) var paused = false + @Published private(set) var repositoryName = "" + @Published private(set) var fileCount = 0 + @Published private(set) var lineCount = 0 + @Published private(set) var chunksTotal = 0 + @Published private(set) var chunksDone = 0 + @Published private(set) var indexSeconds: Double = 0 + @Published private(set) var results: [Result] = [] + @Published private(set) var selected: Result? + @Published private(set) var grepMatches: Int? + @Published private(set) var queryMilliseconds: Double = 0 + @Published private(set) var exampleNumber = 0 + @Published private(set) var stepRemaining = 0 + @Published private(set) var burstQueries = 0 + @Published private(set) var burstPerSecond: Double = 0 + @Published private(set) var burstMilliseconds: Double = 0 + @Published private(set) var feed: [FeedItem] = [] + @Published var query = "" + + private var repository = URL(fileURLWithPath: "/") + private var chunks: [CodeChunk] = [] + private var indexed: [CodeChunk] = [] + /// Every indexed chunk's embedding, row after row, so a query is one matrix-vector product. + private var matrix: [Float] = [] + private var text: EmbeddingGemma2Manager? + private var showTask: Task? + private var searchTask: Task? + private var showQuery = "" + private var snippets: [String: String] = [:] + /// Every indexed file's text, lowercased, for the exact-phrase grep comparison. + private var sources: [String: String] = [:] + private var feedSerial = 0 + private var pausedNanoseconds: UInt64 = 0 + private var pauseBegan: UInt64? + /// Tokens kept per chunk (title + doc comment + the start of the code): 128 indexed FluidAudio in ~28 s with the + /// best top-1 on our question set. + private let maxTokens = + CommandLine.arguments.first { $0.hasPrefix("--tokens=") }.flatMap { Int($0.dropFirst(9)) } ?? 64 + private let speedSeconds = max( + 5, CommandLine.arguments.first { $0.hasPrefix("--segment=") }.flatMap { Double($0.dropFirst(10)) } ?? 30) + private let dwellSeconds = max( + 0.5, CommandLine.arguments.first { $0.hasPrefix("--dwell=") }.flatMap { Double($0.dropFirst(8)) } ?? 1.6) + + var chunksPerSecond: Double { indexSeconds > 0 ? Double(chunksDone) / indexSeconds : 0 } + var isRunning: Bool { [.indexing, .examples, .speed].contains(step) } + var canPlay: Bool { step == .idle || (isRunning && paused) } + var canReplay: Bool { isRunning || step == .finished } + + /// Plain-English questions about FluidAudio whose answers share few or none of their words. + static let examples = [ + "turn text into speech audio", "variational Bayes clustering of speaker embeddings", + "boost recognition of custom vocabulary words", "convert a word into phonemes", + "stream microphone audio into the recognizer", "agglomerative hierarchical clustering", + "print a progress bar while downloading", "resample an audio file to 16 kHz mono", + ] + + private var prepareTask: Task? + + /// Loads once, in a task the model owns, so the window going away or redrawing cannot cancel it. + func start() { + guard prepareTask == nil else { return } + prepareTask = Task { await prepare() } + } + + func prepare() async { + do { + let path = + CommandLine.arguments.first { $0.hasPrefix("--repo=") }.map { String($0.dropFirst(7)) } + ?? "~/Documents/FluidAudio" + repository = URL(fileURLWithPath: (path as NSString).expandingTildeInPath) + repositoryName = repository.lastPathComponent + chunks = CodeChunker.chunks(repository: repository) + guard !chunks.isEmpty else { throw EmbeddingGemma2Error.invalidAsset("No Swift files in \(path)") } + let paths = Set(chunks.map(\.path)) + fileCount = paths.count + for path in paths { + sources[path] = + (try? String(contentsOf: repository.appendingPathComponent(path), encoding: .utf8))?.lowercased() + } + lineCount = sources.values.reduce(0) { + $0 + $1.split(separator: "\n", omittingEmptySubsequences: false).count + } + chunksTotal = chunks.count + DemoLog.event( + "Code search · \(repositoryName): \(fileCount) Swift files, \(lineCount) lines, \(chunks.count) functions and types" + ) + step = .loading("Loading EmbeddingGemma 2 on the Neural Engine…") + let start = DispatchTime.now().uptimeNanoseconds + // The first load of a new build compiles the model for the Neural Engine (a few minutes, once). + let ticker = Task { @MainActor [weak self] in + var seconds = 0 + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(1)) + seconds += 1 + guard let self, case .loading = self.step else { continue } + self.step = .loading( + "Loading EmbeddingGemma 2… \(seconds) s" + + (seconds >= 8 ? " (first launch compiles the model once, ~2 min)" : "")) + } + } + defer { ticker.cancel() } + let text = try await EmbeddingGemma2Manager.loadDefault() + _ = try await text.embed("warm up", prompt: .codeRetrieval) + _ = try await text.embed(Array(repeating: "warm up", count: 64), prompt: .codeRetrieval) + self.text = text + DemoLog.model( + String( + format: "model loaded in %.1f s · 100%% of its ops on the Neural Engine", + Double(DispatchTime.now().uptimeNanoseconds - start) / 1e9)) + step = .idle + if CommandLine.arguments.contains("--autostart") { play() } + } catch { + step = .failed(error.localizedDescription) + DemoLog.line("failed: \(error.localizedDescription)", color: 196, bold: true) + } + } + + // MARK: Play / Pause / Replay + + func play() { + if isRunning, paused { + if let began = pauseBegan { pausedNanoseconds += DispatchTime.now().uptimeNanoseconds - began } + pauseBegan = nil + paused = false + DemoLog.event("▶ resume") + return + } + guard step == .idle else { return } + showTask = Task { await show() } + } + + func pause() { + guard isRunning, !paused else { return } + paused = true + pauseBegan = DispatchTime.now().uptimeNanoseconds + DemoLog.event("❚❚ pause") + } + + func replay() { + guard canReplay else { return } + showTask?.cancel() + paused = false + pauseBegan = nil + indexed = [] + matrix = [] + chunksDone = 0 + indexSeconds = 0 + results = [] + feed = [] + selected = nil + grepMatches = nil + query = "" + showQuery = "" + burstQueries = 0 + burstPerSecond = 0 + burstMilliseconds = 0 + exampleNumber = 0 + step = .idle + DemoLog.event("↺ replay") + showTask = Task { await show() } + } + + private var activeNow: UInt64 { + let now = DispatchTime.now().uptimeNanoseconds + return now - pausedNanoseconds - (pauseBegan.map { now - $0 } ?? 0) + } + + private func waitWhilePaused() async { + while paused, !Task.isCancelled { try? await Task.sleep(for: .milliseconds(50)) } + } + + /// Holds `seconds` of running time. + private func dwell(_ seconds: Double) async { + let start = activeNow + while !Task.isCancelled, Double(activeNow - start) / 1e9 < seconds { + try? await Task.sleep(for: .milliseconds(50)) + } + } + + private func show() async { + DemoLog.event( + "▶ play: index \(repositoryName), \(Self.examples.count) example questions, \(Int(speedSeconds)) s of searches as fast as they go" + ) + await index() + guard !Task.isCancelled, !indexed.isEmpty else { return } + await examplesStep() + guard !Task.isCancelled else { return } + await speed() + guard !Task.isCancelled else { return } + step = .finished + DemoLog.event( + String( + format: "■ done: %d functions and types indexed in %.1f s (%.0f/s); %d searches at %.0f/s", + indexed.count, indexSeconds, chunksPerSecond, burstQueries, burstPerSecond)) + } + + // MARK: Index + + private func index() async { + guard let text else { return } + step = .indexing + DemoLog.event("📥 indexing \(chunks.count) functions and types (\(maxTokens) tokens each, at most)") + let start = activeNow + // Small batches keep the live list moving; packing still fills each Neural Engine call. + let batchSize = 64 + feed = [] + do { + for batchStart in stride(from: 0, to: chunks.count, by: batchSize) { + await waitWhilePaused() + try Task.checkCancellation() + let batch = Array(chunks[batchStart.. Int { + let needle = phrase.lowercased() + return sources.values.filter { $0.contains(needle) }.count + } + + // MARK: Speed + + private func speed() async { + guard let text, !indexed.isEmpty else { return } + step = .speed + let pool = Self.speedQuestions + let dimension = EmbeddingGemma2Manager.dimension + let count = indexed.count + let batchSize = 64 + burstQueries = 0 + feed = [] + DemoLog.event("⚡ search speed: \(pool.count) different questions, back to back, against \(count) functions") + let start = activeNow + let length = UInt64(speedSeconds * 1e9) + var next = 0 + var lastLog = start + var recent: [(time: UInt64, count: Int)] = [(start, 0)] + while !Task.isCancelled, activeNow - start < length { + stepRemaining = max(0, Int((Double(length) - Double(activeNow - start)) / 1e9)) + if paused { + await waitWhilePaused() + recent = [(activeNow, 0)] + continue + } + let batch = (0.. 1_000_000_000 } + let span = Double(now - (recent.first?.time ?? start)) / 1e9 + let recentQueries = recent.dropFirst().reduce(0) { $0 + $1.count } + if span > 0.2, recentQueries > 0 { + burstPerSecond = Double(recentQueries) / span + burstMilliseconds = 1000 / burstPerSecond + } + showQuery = batch[batchSize - 1] + query = showQuery + results = shown + selected = shown.first + grepMatches = nil + queryMilliseconds = burstMilliseconds + if now - lastLog > 1_000_000_000 { + lastLog = now + DemoLog.line( + String( + format: "⚡ %d searches · %.0f/s · %.2f ms each · %.1f M functions ranked/s", burstQueries, + burstPerSecond, burstMilliseconds, burstPerSecond * Double(count) / 1e6), color: 81) + } + } + let elapsed = Double(activeNow - start) / 1e9 + if elapsed > 0, burstQueries > 0 { + burstPerSecond = Double(burstQueries) / elapsed + burstMilliseconds = 1000 / burstPerSecond + } + } + + private func push(_ items: [(String?, CodeChunk)]) { + for (question, chunk) in items { + feedSerial += 1 + feed.insert(FeedItem(id: feedSerial, question: question, chunk: chunk), at: 0) + } + if feed.count > 18 { feed.removeLast(feed.count - 18) } + } + + /// The part of the codebase a path belongs to (`ASR`, `TTS`, `Diarizer`, …), for the coloured tag. + static func area(_ path: String) -> String { + let parts = path.split(separator: "/").map(String.init) + if parts.first == "Tests" { return "Tests" } + if parts.count > 2, parts[0] == "Sources" { + if parts[1].hasSuffix("CLI") { return "CLI" } + return parts.count > 3 ? parts[2] : parts[1] + } + return parts.first ?? path + } + + static func topIndices(_ scores: [Float], row: Int, width: Int, count: Int) -> [Int] { + var best: [(index: Int, score: Float)] = [] + best.reserveCapacity(count + 1) + for column in 0.. best[best.count - 1].score { + let position = best.firstIndex { score > $0.score } ?? best.count + best.insert((column, score), at: position) + if best.count > count { best.removeLast() } + } + } + return best.map(\.index) + } + + // MARK: Search by hand (when the show is not running) + + func search() { + if query == showQuery || isRunning { return } + searchTask?.cancel() + let question = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !question.isEmpty, !indexed.isEmpty else { + results = [] + selected = nil + return + } + searchTask = Task { + try? await Task.sleep(for: .milliseconds(120)) + guard !Task.isCancelled, let ranked = await rank(question), !Task.isCancelled else { return } + results = ranked + selected = ranked.first + grepMatches = grepCount(question) + } + } + + func select(_ result: Result) { + guard !isRunning else { return } + selected = result + } + + /// Top 25 chunks for `question`: one embedding, then one matrix-vector product over every chunk. + private func rank(_ question: String) async -> [Result]? { + guard let text else { return nil } + let begin = DispatchTime.now().uptimeNanoseconds + guard let vector = try? await text.embed(question, prompt: .codeRetrieval) else { return nil } + var scores = [Float](repeating: 0, count: indexed.count) + cblas_sgemv( + CblasRowMajor, CblasNoTrans, Int32(indexed.count), Int32(vector.count), 1, matrix, Int32(vector.count), + vector, 1, 0, &scores, 1) + let ranked = scores.indices.sorted { scores[$0] > scores[$1] }.prefix(25).map { + Result(chunk: indexed[$0], score: scores[$0]) + } + queryMilliseconds = Double(DispatchTime.now().uptimeNanoseconds - begin) / 1e6 + DemoLog.line( + String(format: "🔎 “%@” → %d functions ranked in %.0f ms", question, indexed.count, queryMilliseconds), + color: 81) + return ranked + } + + /// The declaration as it is in the file (indentation kept), up to 60 lines. + func snippet(_ chunk: CodeChunk) -> String { + if let cached = snippets[chunk.path + ":\(chunk.line)"] { return cached } + guard let text = try? String(contentsOf: repository.appendingPathComponent(chunk.path), encoding: .utf8) + else { return chunk.code } + let lines = text.components(separatedBy: "\n") + var first = chunk.line - 1 + while first > 0, lines[first - 1].trimmingCharacters(in: .whitespaces).hasPrefix("///") { first -= 1 } + let slice = Array(lines[first.. 0, !model.results.isEmpty { + Text(String(format: "%.0f ms", model.queryMilliseconds)).font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 16).padding(.vertical, 12) + } +} + +struct Results: View { + @EnvironmentObject private var model: CodeSearchModel + + var body: some View { + ScrollView { + LazyVStack(spacing: 0) { + if model.step == .indexing || model.step == .speed { + // Live list: functions as they are indexed, or questions as they are answered. + ForEach(model.feed) { item in + FeedRow(item: item) + .transition(.move(edge: .top).combined(with: .opacity)) + Divider().opacity(0.4) + } + } else { + ForEach(Array(model.results.prefix(12).enumerated()), id: \.element.id) { rank, result in + ResultRow(rank: rank + 1, result: result, selected: model.selected?.id == result.id) + .onTapGesture { model.select(result) } + Divider().opacity(0.4) + } + } + } + .animation(.easeOut(duration: 0.15), value: model.feed.map(\.id)) + } + .background(Color(red: 0.11, green: 0.12, blue: 0.14)) + .overlay { + if model.results.isEmpty, model.feed.isEmpty { + Text(model.step == .finished ? "Ask a question" : "Press Play").foregroundStyle(.secondary) + } + } + } +} + +/// `Type.member` with the type in the type colour and the member bold in the function colour. +struct SymbolName: View { + let name: String + var size: CGFloat = 13 + + var body: some View { + let parts = name.split(separator: ".", maxSplits: 1).map(String.init) + let owner = parts.count == 2 ? Text(parts[0] + ".").foregroundColor(SwiftHighlighter.type) : Text("") + let member = Text(parts.last ?? name).bold() + .foregroundColor(parts.count == 2 ? SwiftHighlighter.call : SwiftHighlighter.type) + return (owner + member).font(.system(size: size, design: .monospaced)).lineLimit(1) + } +} + +struct AreaTag: View { + let path: String + + var body: some View { + let area = CodeSearchModel.area(path) + let color = SwiftHighlighter.areaColor(area) + Text(area).font(.system(size: 10, weight: .bold, design: .rounded)).foregroundStyle(color) + .padding(.horizontal, 6).padding(.vertical, 2) + .background(Capsule().fill(color.opacity(0.18))) + } +} + +struct FeedRow: View { + let item: CodeSearchModel.FeedItem + + var body: some View { + HStack(spacing: 10) { + AreaTag(path: item.chunk.path).frame(width: 64, alignment: .leading) + if let question = item.question { + Text(question).font(.system(size: 12)).foregroundStyle(.primary.opacity(0.85)).lineLimit(1) + Image(systemName: "arrow.right").font(.system(size: 10, weight: .bold)).foregroundStyle(.secondary) + } + SymbolName(name: item.chunk.name, size: 12) + Spacer(minLength: 0) + } + .padding(.horizontal, 14).padding(.vertical, 5) + } +} + +struct ResultRow: View { + let rank: Int + let result: CodeSearchModel.Result + let selected: Bool + + var body: some View { + let color = SwiftHighlighter.areaColor(CodeSearchModel.area(result.chunk.path)) + HStack(spacing: 12) { + Text("\(rank)").font(.system(.callout, design: .rounded).bold().monospacedDigit()) + .foregroundStyle(rank <= 3 ? color : .secondary).frame(width: 22) + VStack(alignment: .leading, spacing: 4) { + SymbolName(name: result.chunk.name) + HStack(spacing: 6) { + AreaTag(path: result.chunk.path) + Text("\(result.chunk.path):\(result.chunk.line)").font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.head) + } + } + Spacer() + VStack(alignment: .trailing, spacing: 3) { + Text(String(format: "%.3f", result.score)).font(.caption.monospacedDigit()).foregroundStyle(.secondary) + GeometryReader { proxy in + Capsule().fill(Color.primary.opacity(0.08)) + .overlay(alignment: .leading) { + // Scores here sit around 0.6–0.85; stretch that range across the bar. + Capsule().fill( + LinearGradient( + colors: [color.opacity(0.6), color], startPoint: .leading, endPoint: .trailing) + ) + .frame(width: proxy.size.width * CGFloat(min(max((result.score - 0.6) / 0.25, 0.05), 1))) + } + } + .frame(width: 70, height: 5) + } + } + .padding(.horizontal, 16).padding(.vertical, 8) + .background(selected ? color.opacity(0.16) : .clear) + .overlay(alignment: .leading) { if selected { Rectangle().fill(color).frame(width: 3) } } + } +} + +struct CodePreview: View { + @EnvironmentObject private var model: CodeSearchModel + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let result = model.selected { + HStack(spacing: 8) { + AreaTag(path: result.chunk.path) + SymbolName(name: result.chunk.name, size: 14) + Spacer() + Text("\(result.chunk.path):\(result.chunk.line)").font(.caption).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.head) + } + .padding(12) + Divider() + ScrollView { + Text(SwiftHighlighter.highlight(model.snippet(result.chunk))) + .font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading).padding(12) + .textSelection(.enabled) + } + } else { + Spacer() + Text("The matching code shows here").foregroundStyle(.secondary).frame(maxWidth: .infinity) + Spacer() + } + } + .background(Color(red: 0.12, green: 0.12, blue: 0.14)) + .environment(\.colorScheme, .dark) + } +} + +struct Tile: View { + let value: String + let caption: String + var tint: Color = .primary + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(value).font(.system(size: 24, weight: .semibold, design: .rounded)).monospacedDigit() + .foregroundStyle(tint) + .contentTransition(.numericText()) + Text(caption).font(.caption).foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(RoundedRectangle(cornerRadius: 10).fill(Color.primary.opacity(0.05))) + } +} diff --git a/Sources/CodeSearchDemo/DemoLog.swift b/Sources/CodeSearchDemo/DemoLog.swift new file mode 100644 index 0000000..e8e4396 --- /dev/null +++ b/Sources/CodeSearchDemo/DemoLog.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Colour-coded console lines for the side terminal (`demo.sh` tails stdout): model load, indexing, searches (code search). +enum DemoLog { + /// 256-colour codes matching the collection colours. + private static let topicColors = [33, 208, 34, 205, 135, 37, 196, 61, 130, 121] + + static func line(_ text: String, color: Int? = nil, bold: Bool = false) { + let time = timeFormatter.string(from: Date()) + var styled = text + if let color { styled = "\u{1B}[38;5;\(color)m\(styled)\u{1B}[0m" } + if bold { styled = "\u{1B}[1m\(styled)\u{1B}[0m" } + print("\u{1B}[2m\(time)\u{1B}[0m \(styled)") + } + + static func colored(_ text: String, slot: Int) -> String { + "\u{1B}[38;5;\(topicColors[slot % topicColors.count])m\(text)\u{1B}[0m" + } + + static func event(_ text: String) { line(text, color: 220, bold: true) } + + static func model(_ text: String) { line("[model] " + text, color: 81) } + + private static let timeFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm:ss.SSS" + return formatter + }() +} diff --git a/Sources/CodeSearchDemo/README.md b/Sources/CodeSearchDemo/README.md new file mode 100644 index 0000000..3e0e7b8 --- /dev/null +++ b/Sources/CodeSearchDemo/README.md @@ -0,0 +1,30 @@ +# Code search + +Ask a codebase questions in plain English and get the function that does it, even when the answer shares none of +the question's words. EmbeddingGemma 2 (text model, 100% on the Neural Engine) embeds every Swift function and type; +a question is one more embedding and one matrix-vector product. + +```bash +swift run -c release CodeSearchDemo # indexes ~/Documents/FluidAudio +swift run -c release CodeSearchDemo --repo=~/Code/MyApp +Sources/CodeSearchDemo/demo.sh # same app, plus one terminal: macmon above the live log +``` + +**Play** (↩) runs the show once: **📥 index** every declaration (`CodeChunker`: one chunk per `func` / `init` / type, +doc comment included, embedded as `title: | text: ` truncated to 64 tokens; the list streams each function as it is indexed), then **🔎 eight +example questions** (each typed; the top result's code opens, syntax-coloured, then #2 and #3; the header shows how many files an +exact-phrase grep finds), then **⚡ search speed** for 30 s (~70 different questions back to back, 64 at a time, +ranked against every function with one matrix multiply; the list streams question → answer). **Pause** (Space) holds it anywhere; **Replay** (⌘R) starts +over. When it is done, ask your own questions. `--segment=`, `--dwell=`, `--tokens=`, `--autostart` adjust it. + +FluidAudio on an M5 Pro (macOS 27): + +| | | +|---|---| +| Index 5,617 functions and types (483 files, 125k lines) | 15.9 s, 353 per second (64 tokens; 28.5 s at 128) | +| The eight example questions | a right function first on all eight; exact-phrase grep finds 0 files for seven | +| Search speed | 712 searches/s, ~1.4 ms each | +| Known-answer set (`CodeSearchCheck --eval`, 16 questions) | 11/16 first, 12/16 in the top 5 | + +`CodeSearchCheck` scores chunk lengths and title formats: file name + symbol in the title beat symbol alone or file +alone; 64 tokens indexes ~1.7× faster with a lower top-1 (9/16) and a higher top-5 (14/16). diff --git a/Sources/CodeSearchDemo/SwiftHighlighter.swift b/Sources/CodeSearchDemo/SwiftHighlighter.swift new file mode 100644 index 0000000..c7260f3 --- /dev/null +++ b/Sources/CodeSearchDemo/SwiftHighlighter.swift @@ -0,0 +1,107 @@ +import SwiftUI + +/// Xcode-dark-style colouring for a Swift snippet whose lines start with a 7-character line-number gutter. +enum SwiftHighlighter { + static let keyword = Color(red: 0.99, green: 0.37, blue: 0.64) + static let type = Color(red: 0.36, green: 0.85, blue: 1.0) + static let call = Color(red: 0.40, green: 0.72, blue: 0.64) + static let declaration = Color(red: 0.25, green: 0.63, blue: 0.75) + static let string = Color(red: 0.99, green: 0.42, blue: 0.36) + static let number = Color(red: 0.82, green: 0.75, blue: 0.41) + static let comment = Color(red: 0.47, green: 0.53, blue: 0.60) + static let attribute = Color(red: 0.99, green: 0.56, blue: 0.25) + static let plain = Color(white: 0.92) + static let gutter = Color(white: 0.42) + + private static let keywords: Set = [ + "func", "let", "var", "if", "else", "guard", "return", "for", "in", "while", "repeat", "switch", "case", + "default", "break", "continue", "struct", "class", "enum", "actor", "protocol", "extension", "import", "init", + "deinit", "self", "Self", "super", "static", "private", "fileprivate", "internal", "public", "open", "final", + "override", "mutating", "nonisolated", "async", "await", "try", "throws", "rethrows", "throw", "do", "catch", + "where", "nil", "true", "false", "inout", "some", "any", "typealias", "associatedtype", "lazy", "weak", + "unowned", "defer", "is", "as", "subscript", "convenience", "required", "get", "set", "willSet", "didSet", + ] + private static let token = try! NSRegularExpression( + pattern: #"(//.*$)|("(?:\\.|[^"\\])*")|(@[A-Za-z_]+)|(\b\d+(?:\.\d+)?\b)|([A-Za-z_][A-Za-z0-9_]*)"#) + + static func highlight(_ snippet: String) -> AttributedString { + var result = AttributedString() + for (index, line) in snippet.components(separatedBy: "\n").enumerated() { + if index > 0 { result.append(AttributedString("\n")) } + let gutterLength = min(7, line.count) + var gutter = AttributedString(String(line.prefix(gutterLength))) + gutter.foregroundColor = gutterColor + result.append(gutter) + result.append(code(String(line.dropFirst(gutterLength)))) + } + return result + } + + private static let gutterColor = gutter + + /// Colours one line of Swift. + static func code(_ line: String) -> AttributedString { + var result = AttributedString() + let ns = line as NSString + var cursor = 0 + var previousWord = "" + for match in token.matches(in: line, range: NSRange(location: 0, length: ns.length)) { + if match.range.location > cursor { + result.append( + piece(ns.substring(with: NSRange(location: cursor, length: match.range.location - cursor)), plain)) + } + let text = ns.substring(with: match.range) + let color: Color + if match.range(at: 1).location != NSNotFound { + color = comment + } else if match.range(at: 2).location != NSNotFound { + color = string + } else if match.range(at: 3).location != NSNotFound { + color = attribute + } else if match.range(at: 4).location != NSNotFound { + color = number + } else if keywords.contains(text) { + color = keyword + } else if ["func", "struct", "class", "enum", "actor", "protocol", "extension"].contains(previousWord) { + color = declaration + } else if text.first?.isUppercase == true { + color = type + } else if match.range.location + match.range.length < ns.length, + ns.character(at: match.range.location + match.range.length) == 0x28 // "(" + { + color = call + } else { + color = plain + } + result.append(piece(text, color)) + if match.range(at: 5).location != NSNotFound { previousWord = text } + cursor = match.range.location + match.range.length + } + if cursor < ns.length { result.append(piece(ns.substring(from: cursor), plain)) } + return result + } + + private static func piece(_ text: String, _ color: Color) -> AttributedString { + var part = AttributedString(text) + part.foregroundColor = color + return part + } + + /// Fixed colours for the usual parts of an audio SDK, hashed ones for anything else. + static func areaColor(_ area: String) -> Color { + switch area { + case "ASR": return .blue + case "TTS": return .orange + case "Diarizer": return .green + case "VAD": return .purple + case "Shared": return .teal + case "ITN": return .pink + case "CLI": return .yellow + case "Tests": return Color(white: 0.6) + default: + // Stable across launches (String.hashValue is seeded per process). + let palette: [Color] = [.mint, .indigo, .cyan, .red, .brown] + return palette[area.unicodeScalars.reduce(0) { $0 &+ Int($1.value) } % palette.count] + } + } +} diff --git a/Sources/CodeSearchDemo/demo.sh b/Sources/CodeSearchDemo/demo.sh new file mode 100755 index 0000000..dd45766 --- /dev/null +++ b/Sources/CodeSearchDemo/demo.sh @@ -0,0 +1,34 @@ +#!/bin/zsh +# Code search demo: the app, plus one terminal (Ghostty if installed) with macmon (GPU / ANE / power; asitop as a +# fallback) on top and the live Neural Engine log below. +# Sources/CodeSearchDemo/demo.sh [app options, e.g. --autostart --auto-split --rate=200] +set -e +ROOT=$(cd "$(dirname "$0")/../.." && pwd) +LOG=${TMPDIR:-/tmp}/code-search-demo.log +# macmon (brew install macmon) needs no sudo and works on M5; asitop 0.0.24 crashes there (KeyError E0-Cluster_active). +if command -v macmon >/dev/null; then + MONITOR="$(command -v macmon)" +else + MONITOR="sudo sh -c 'pkill -x powermetrics; exec $(command -v asitop || echo "$HOME/.local/bin/asitop")'" +fi + +swift build -c release --product CodeSearchDemo --package-path "$ROOT" +: > "$LOG" +# One tmux session, reused across launches: the monitor on top, the log below. +if ! tmux has-session -t code-search 2>/dev/null; then + # The pane stays open if the monitor exits, so the reason is visible. + tmux new-session -d -s code-search -x 160 -y 70 \ + "$MONITOR; echo; echo \"monitor exited (\$?) — press Enter to retry\"; read; $MONITOR" + tmux set-option -t code-search remain-on-exit on + tmux split-window -v -t code-search "tail -n 300 -F '$LOG'" + if [[ -d /Applications/Ghostty.app ]]; then + open -na Ghostty --args -e "$(command -v tmux)" attach -t code-search + else + osascript -e 'tell application "Terminal" to do script "tmux attach -t code-search"' \ + -e 'tell application "Terminal" to activate' >/dev/null + fi +fi +pkill -f "release/CodeSearchDemo" 2>/dev/null || true +cd "$ROOT" +"$ROOT/.build/release/CodeSearchDemo" "$@" > "$LOG" 2> "$LOG.stderr" & +echo "demo pid $! · log $LOG · press Play (↩) · Pause (Space) · Replay (⌘R)" diff --git a/Sources/FluidUse/EmbeddingGemma2/EmbeddingGemma2Manager.swift b/Sources/FluidUse/EmbeddingGemma2/EmbeddingGemma2Manager.swift index c245b1f..a856dfb 100644 --- a/Sources/FluidUse/EmbeddingGemma2/EmbeddingGemma2Manager.swift +++ b/Sources/FluidUse/EmbeddingGemma2/EmbeddingGemma2Manager.swift @@ -85,8 +85,12 @@ public final class EmbeddingGemma2Manager: Sendable { /// `maxInFlight` calls run concurrently (Core ML's async prediction is thread-safe). public static let maxInFlight = 8 - public func embed(_ texts: [String], prompt: EmbeddingGemma2Prompt = .none) async throws -> [[Float]] { - let tokenized = texts.map { tokenizer.encode(prompt.apply(to: $0), maxLength: Self.lengths.last!) } + /// `maxTokens` (≤ 512) truncates each text first: shorter texts pack more per call. + public func embed( + _ texts: [String], prompt: EmbeddingGemma2Prompt = .none, maxTokens: Int = 512 + ) async throws -> [[Float]] { + let limit = min(max(maxTokens, 3), Self.lengths.last!) + let tokenized = texts.map { tokenizer.encode(prompt.apply(to: $0), maxLength: limit) } var bins: [[Int]] = [] var singles: [Int] = [] var current: [Int] = [] diff --git a/Tests/CodeSearchTests/CodeChunkerTests.swift b/Tests/CodeSearchTests/CodeChunkerTests.swift new file mode 100644 index 0000000..ac6f5ac --- /dev/null +++ b/Tests/CodeSearchTests/CodeChunkerTests.swift @@ -0,0 +1,53 @@ +import XCTest + +@testable import CodeSearch + +final class CodeChunkerTests: XCTestCase { + private let source = """ + import Foundation + + /// Converts audio. + public struct AudioConverter { + /// Resamples to 16 kHz mono. + public func resample(_ samples: [Float]) -> [Float] { + samples + } + + class func shared() -> AudioConverter { AudioConverter() } + + init() {} + } + + extension AudioConverter { + @discardableResult + static func write(_ samples: [Float]) -> Bool { true } + } + """ + + func testDeclarationsAndOwners() { + let chunks = CodeChunker.chunks(source: source, path: "Sources/AudioConverter.swift") + XCTAssertEqual( + chunks.map(\.name), + [ + "AudioConverter", "AudioConverter.resample", "AudioConverter.shared", "AudioConverter.init", + "AudioConverter", + "AudioConverter.write", + ]) + XCTAssertEqual(chunks.map(\.kind), ["struct", "func", "func", "init", "extension", "func"]) + } + + func testLinesAndDocComments() { + let chunks = CodeChunker.chunks(source: source, path: "Sources/AudioConverter.swift") + let resample = chunks[1] + XCTAssertEqual(resample.line, 6) + XCTAssertTrue(resample.code.hasPrefix("/// Resamples to 16 kHz mono.")) + // The next declaration's doc comment stays with it. + XCTAssertFalse(chunks[0].code.contains("Resamples")) + XCTAssertTrue(chunks[5].code.hasPrefix("@discardableResult")) + } + + func testDocumentFormat() { + let chunk = CodeChunker.chunks(source: source, path: "Sources/AudioConverter.swift")[1] + XCTAssertTrue(chunk.document.hasPrefix("title: AudioConverter.swift AudioConverter.resample | text: ")) + } +}