File size: 11,358 Bytes
dd5890c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
import Foundation

// Faithful Swift port of canonical `hypernet_sp/memory_core.py` — lexical/semantic matching
// helpers + TieredMemory (L1 session / L2 disk / pins). Behaviour mirrors the reference.

private let jpStop: Set<String> = Set(
    "です ます した して いる ある する それ これ あれ どこ なに 何で ですか ました".split(separator: " ").map(String.init))

/// True if the scalar is in the kana/CJK ranges used by `_CJK`.
private func isCJK(_ u: Unicode.Scalar) -> Bool {
    let v = u.value
    return (0x3040...0x30FF).contains(v)       // hiragana + katakana (぀-ヿ)
        || (0x3400...0x9FFF).contains(v)       // CJK unified (㐀-鿿)
        || (0xF900...0xFAFF).contains(v)       // CJK compat (豈-﫿)
}

private func cjkRuns(_ s: String) -> [String] {
    var runs = [String](); var cur = ""
    for ch in s.unicodeScalars {
        if isCJK(ch) { cur.unicodeScalars.append(ch) }
        else if !cur.isEmpty { runs.append(cur); cur = "" }
    }
    if !cur.isEmpty { runs.append(cur) }
    return runs
}

/// `_words`: content tokens for lexical overlap; CJK runs contribute character bigrams.
func mcWords(_ s: String) -> Set<String> {
    var out = Set<String>()
    for w in regexMatches(s.lowercased(), "[a-z0-9][a-z0-9\\-]*") where w.count >= 2 && !spStop.contains(w) {
        out.insert(w)
    }
    for run in cjkRuns(s) where run.count >= 2 {
        let chars = Array(run)
        for i in 0 ..< (chars.count - 1) {
            let bg = String(chars[i ... i + 1])
            if !jpStop.contains(bg) { out.insert(bg) }
        }
    }
    return out
}

/// `_overlap`: count of query words matching a target word (exact or ≥4-char shared prefix).
func mcOverlap(_ qw: Set<String>, _ tw: Set<String>) -> Int {
    var n = 0
    for a in qw {
        if tw.contains(where: { b in
            a == b || (a.count >= 4 && b.count >= 4 && (a.hasPrefix(b) || b.hasPrefix(a)))
        }) { n += 1 }
    }
    return n
}

/// `_matches`: lexical top-`cap` chunks (within 1 of best overlap), in chronological order.
func mcMatches(_ query: String, _ store: [String], cap: Int = 4) -> [String] {
    let qw = mcWords(query)
    var scored = store.enumerated().map { (i, t) in (s: mcOverlap(qw, mcWords(t)), i: i, t: t) }
        .filter { $0.s > 0 }
    guard let hi = scored.map({ $0.s }).max() else { return [] }
    let near = scored.filter { $0.s >= hi - 1 }
    let top = near.sorted { ($0.s, -$0.i) > ($1.s, -$1.i) }.prefix(cap)
    return top.sorted { $0.i < $1.i }.map { $0.t }
}

private let correctionRE = "\\b(actually|instead|scratch that|correction|rather|no wait|wait no|make it|changed?( it| to)?|should be|moved to|now it'?s|\\bnot\\b)"

/// `_with_amendments`: any correction line that follows and shares a word with a selected line rides along.
func withAmendments(_ selected: [String], _ store: [String]) -> [String] {
    var sel = Set(selected)
    var changed = true
    while changed {
        changed = false
        for (i, t) in store.enumerated() {
            if sel.contains(t) || regexMatches(t, correctionRE, caseInsensitive: true).isEmpty { continue }
            let tw = mcWords(t)
            for (j, s) in store.enumerated() where sel.contains(s) && j < i && mcOverlap(mcWords(s), tw) >= 1 {
                sel.insert(t); changed = true; break
            }
        }
    }
    return store.filter { sel.contains($0) }
}

/// `mark_superseded`: tag corrected lines `(outdated)` and corrections `(current)`.
func markSuperseded(_ chunks: [String]) -> [String] {
    let n = chunks.count
    var superseded = [Bool](repeating: false, count: n)
    var corrects = [Bool](repeating: false, count: n)
    for i in 0 ..< n {
        if regexMatches(chunks[i], correctionRE, caseInsensitive: true).isEmpty { continue }
        let cw = mcWords(chunks[i])
        for j in 0 ..< i where mcOverlap(mcWords(chunks[j]), cw) >= 1 {
            superseded[j] = true; corrects[i] = true
        }
    }
    return chunks.enumerated().map { (i, c) in
        superseded[i] ? "(outdated) \(c)" : (corrects[i] ? "(current) \(c)" : c)
    }
}

private let qLead = ["what", "what's", "whats", "who", "who's", "whose", "when", "where",
                     "which", "how", "why", "recall", "remind", "find", "do you know",
                     "tell me", "is my", "are my"]

func mcIsQuestion(_ text: String) -> Bool {
    let t = text.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
    return t.hasSuffix("?") || qLead.contains { t.hasPrefix($0) }
}

private let mathRE = "how (much|many)|calculat|comput|percent|%|\\bplus\\b|\\bminus\\b|times|divided|average|\\btotal\\b|\\bsum\\b|sqrt|\\d\\s*[-+*/=]\\s*\\d"
func mcLooksMathy(_ q: String) -> Bool {
    return !regexMatches(q, "\\d").isEmpty && !regexMatches(q.lowercased(), mathRE).isEmpty
}

func mcWantsPersist(_ text: String) -> Bool {
    !regexMatches(text.lowercased(), "remember|save|profile|note|for later|always").isEmpty
}

private let factlikeRE = "\\d|\\b(is|are|am|was|were|will|'ll|named|called|theme|colou?r|name|make it|change|instead|order|paint|costs?|each)\\b"
func mcFactlike(_ text: String) -> Bool {
    !regexMatches(text, factlikeRE, caseInsensitive: true).isEmpty
}
func mcIsFactlike(_ text: String) -> Bool { !mcIsQuestion(text) && mcFactlike(text) }

/// `_answer_ok`: empty/degenerate → bad; if chunks present, must be grounded.
func answerOk(_ answer: String, _ chunks: [String], _ question: String) -> Bool {
    let a = answer.trimmingCharacters(in: .whitespacesAndNewlines)
    if isEmptyAnswer(a) { return false }
    if looksDegenerate(a) || looksGarbage(a) { return false }
    if !chunks.isEmpty && !isGrounded(answer: answer, chunks: chunks, question: question) { return false }
    return true
}

/// Tiered memory (L1 session / L2 disk / pins). Strings are stored; embeddings are cached per
/// string and matched with BGE — mirroring the reference `_sem_matches` / `_match`.
public final class TieredMemory {
    public static let PINCAP = 12
    public static let LOGCAP = 16

    public private(set) var session: [String] = []
    public private(set) var persistent: [String] = []
    public private(set) var pins: [String] = []

    private let bge: Embedder?
    private let path: URL?
    private var embCache: [String: [Float]] = [:]

    public init(bge: Embedder?, l2URL: URL? = nil) {
        self.bge = bge
        self.path = l2URL
        if let url = l2URL, let data = try? String(contentsOf: url, encoding: .utf8) {
            for line in data.split(separator: "\n") where !line.isEmpty {
                if let d = try? JSONSerialization.jsonObject(with: Data(line.utf8)) as? [String: Any],
                    let t = d["text"] as? String { persistent.append(t) }
            }
        }
    }

    public func remember_session(_ t: String) { session.append(t) }
    /// A recompute supersedes the previous result line (working state, not history).
    public func dropComputedResults() { session.removeAll { $0.hasPrefix("Earlier computed result:") } }
    public func persist(_ t: String) {
        session.append(t); persistent.append(t)
        if let url = path {
            let line = String(data: try! JSONSerialization.data(withJSONObject: ["text": t]), encoding: .utf8)! + "\n"
            if let h = try? FileHandle(forWritingTo: url) {
                h.seekToEndOfFile(); h.write(Data(line.utf8)); try? h.close()
            } else { try? line.write(to: url, atomically: true, encoding: .utf8) }
        }
    }
    public func pin(_ t: String) {
        pins.removeAll { $0 == t }
        pins.append(t)
        if pins.count > Self.PINCAP { pins = Array(pins.suffix(Self.PINCAP)) }
    }

    public var counts: (l1: Int, pins: Int, l2: Int) { (session.count, pins.count, persistent.count) }

    private func embed(_ t: String, isQuery: Bool) async -> [Float] {
        if !isQuery, let c = embCache[t] { return c }
        guard let bge else { return [] }
        let e = await bge.encode(t, isQuery: isQuery)
        if !isQuery { embCache[t] = e }
        return e
    }

    /// `_sem_matches`: cosine ≥ min_sim, within top_delta of best, recency tie-break, cap.
    func semMatches(_ query: String, _ store: [String], minSim: Float = 0.46,
                    topDelta: Float = 0.12, recEps: Float = 0.05, cap: Int = 4) async -> [String]? {
        guard bge != nil, !store.isEmpty else { return nil }
        let qv = await embed(query, isQuery: true)
        var sims = [Float](); sims.reserveCapacity(store.count)
        for t in store { sims.append(cosine(await embed(t, isQuery: false), qv)) }
        var keep = (0 ..< store.count).filter { sims[$0] >= minSim }
        if keep.isEmpty { return [] }
        let hi = keep.map { sims[$0] }.max()!
        keep = keep.filter { sims[$0] >= hi - topDelta }
        keep.sort { (a, b) in
            let ra = -(sims[a] / recEps).rounded(), rb = -(sims[b] / recEps).rounded()
            return ra != rb ? ra < rb : a > b
        }
        return Array(keep.prefix(cap)).sorted().map { store[$0] }
    }

    /// `_match`: semantic-first for English; lexical-first (raised bar) for CJK; then amendments.
    func match(_ query: String, _ store: [String], cap: Int = 4) async -> [String] {
        if !cjkRuns(query).isEmpty {
            let lex = mcMatches(query, store, cap: cap)
            if !lex.isEmpty { return withAmendments(lex, store) }
            let r = await semMatches(query, store, minSim: 0.72, topDelta: 0.06, cap: cap)
            return (r?.isEmpty == false) ? withAmendments(r!, store) : []
        }
        var r = await semMatches(query, store, cap: cap)
        if r == nil { r = mcMatches(query, store, cap: cap) }
        return (r?.isEmpty == false) ? withAmendments(r!, store) : []
    }

    /// `retrieve_known`: STRICT personal probe for lookup turns (min_sim 0.6 + shared content word).
    public func retrieveKnown(_ query: String, minSim: Float = 0.6) async -> (src: String?, chunks: [String]) {
        let pool = persistent.filter { !session.contains($0) } + session
        var hits = (await semMatches(query, pool, minSim: minSim)) ?? []
        let qw = mcWords(query)
        hits = hits.filter { mcOverlap(qw, mcWords($0)) >= 1 }
        if hits.isEmpty { return (nil, []) }
        let tiers = hits.map { session.contains($0) ? "L1·same-session" : "L2·prior-session" }
        return (orderedJoin(tiers), withAmendments(hits, pool))
    }

    /// `retrieve_personal`: L1+L2 ranked as one pool via `_match`, then pins fallback.
    public func retrievePersonal(_ query: String) async -> (src: String?, chunks: [String]) {
        let pool = persistent.filter { !session.contains($0) } + session
        let hits = await match(query, pool)
        if !hits.isEmpty {
            let tiers = hits.map { session.contains($0) ? "L1·same-session" : "L2·prior-session" }
            return (orderedJoin(tiers), hits)
        }
        if !pins.isEmpty, let mp = await semMatches(query, pins, minSim: 0.5), !mp.isEmpty {
            return ("WM·pins", mp)
        }
        return (nil, [])
    }

    private func orderedJoin(_ tiers: [String]) -> String {
        var seen = Set<String>(); var out = [String]()
        for t in tiers where seen.insert(t).inserted { out.append(t) }
        return out.joined(separator: "+")
    }
}