foldkeep 0.9.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
foldkeep/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """FoldKeep -- Agent Context Infrastructure.
2
+
3
+ Layers:
4
+ compressor : FastLingua engine (entity-preserving heuristic compression)
5
+ estimate : token & cost estimation across popular models
6
+ store : named compressed-context snapshots for agent workflows
7
+
8
+ Layers: core.compressor | estimate | store(SQLite memory bank) | packing | mcp_server.
9
+ """
10
+ __version__ = "0.6.0"
11
+
12
+ from .core.compressor import FastLingua # noqa: F401
13
+ from .benchmark.suite import run_bench, evolve # noqa: F401
14
+ from .estimate import estimate_tokens, estimate_cost, savings # noqa: F401
15
+ from .memory import store # noqa: F401
16
+ from .packing import pack, render # noqa: F401
foldkeep/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
foldkeep/cli.py ADDED
@@ -0,0 +1,246 @@
1
+ """FoldKeep unified CLI: oc compress / estimate / store / bench / evolve."""
2
+ import argparse
3
+ import json
4
+ import os
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .core.compressor import FastLingua, GENES
9
+ from .benchmark.suite import run_bench, evolve, EVOLVE_FILE
10
+ from .memory import store
11
+ from .estimate import estimate_cost, savings
12
+ from .packing import pack, render
13
+ from .diff import commit as ctx_commit, log as ctx_log, show as ctx_show
14
+ from . import session as sess
15
+
16
+ def _read(src):
17
+ if src == "-":
18
+ raw = sys.stdin.buffer.read()
19
+ try:
20
+ return raw.decode("utf-8")
21
+ except UnicodeDecodeError:
22
+ return raw.decode("gbk", "replace")
23
+ with open(src, encoding="utf-8-sig") as f:
24
+ return f.read().lstrip("\ufeff")
25
+
26
+ def _adapt(fl, stats, target):
27
+ try:
28
+ cur = {}
29
+ if os.path.exists(EVOLVE_FILE):
30
+ with open(EVOLVE_FILE, encoding="utf-8") as f:
31
+ cur = json.load(f)
32
+ err = stats["ratio"] - target
33
+ if abs(err) > 0.05:
34
+ lo, hi = GENES["dup_th"]
35
+ d = float(cur.get("dup_th", 0.72)) - (0.01 if err > 0 else -0.008)
36
+ cur["dup_th"] = round(min(hi, max(lo, d)), 4)
37
+ lo, hi = GENES["ent_w"]
38
+ d = float(cur.get("ent_w", 3.0)) - (0.08 if err > 0 else -0.10)
39
+ cur["ent_w"] = round(min(hi, max(lo, d)), 4)
40
+ lo, hi = GENES["cont_w"]
41
+ d = float(cur.get("cont_w", 1.0)) - (0.05 if err > 0 else -0.04)
42
+ cur["cont_w"] = round(min(hi, max(lo, d)), 4)
43
+ with open(EVOLVE_FILE, "w", encoding="utf-8") as f:
44
+ json.dump(cur, f, indent=1)
45
+ except Exception:
46
+ pass
47
+
48
+ def main(argv=None):
49
+ ap = argparse.ArgumentParser(
50
+ prog="foldkeep",
51
+ description="FoldKeep: Agent Context Infrastructure (compress | estimate | store | bench | evolve)")
52
+ ap.add_argument("--version", action="version", version="foldkeep " + __version__)
53
+ sub = ap.add_subparsers(dest="cmd", required=True)
54
+
55
+ c = sub.add_parser("compress", help="compress text via FastLingua engine")
56
+ c.add_argument("input", help="file path or '-' for stdin")
57
+ c.add_argument("--ratio", type=float, default=0.5)
58
+ c.add_argument("--mode", choices=FastLingua.MODES, default="balanced")
59
+ c.add_argument("--anchors", action="store_true")
60
+ c.add_argument("--collapse", action="store_true")
61
+ c.add_argument("--adapt", action="store_true")
62
+ c.add_argument("--no-auto", action="store_true")
63
+ c.add_argument("--save", metavar="NAME", help="also store snapshot for later reuse")
64
+ c.add_argument("-o", "--output")
65
+
66
+ e = sub.add_parser("estimate", help="token & cost estimate")
67
+ e.add_argument("input")
68
+ e.add_argument("--model", default="claude-sonnet")
69
+ e.add_argument("--compress", action="store_true", help="also show post-compression savings")
70
+
71
+ st = sub.add_parser("store", help="snapshot management")
72
+ st.add_argument("op", choices=["list", "get", "delete"])
73
+ st.add_argument("name", nargs="?")
74
+
75
+ b = sub.add_parser("bench", help="run benchmark")
76
+ b.add_argument("--full", action="store_true", help="1000-sample synthetic suite")
77
+
78
+ pk = sub.add_parser("pack", help="fit multiple files into a token budget")
79
+ pk.add_argument("inputs", nargs="+")
80
+ pk.add_argument("--budget", type=int, default=8000)
81
+ pk.add_argument("--ratio", type=float, default=0.5)
82
+ pk.add_argument("--model", default=None, help="also show cost for this model")
83
+ pk.add_argument("--save", metavar="NAME")
84
+ pk.add_argument("-o", "--output")
85
+ sub.add_parser("selftest", help="run the 16-case edge battery")
86
+
87
+ ss = sub.add_parser("session", help="long-conversation rolling compression")
88
+ ss.add_argument("op", choices=["new", "push", "show", "stat"])
89
+ ss.add_argument("name")
90
+ ss.add_argument("-r", "--role", default="user", choices=["user", "assistant", "system"])
91
+ ss.add_argument("-t", "--text")
92
+ ss.add_argument("-f", "--file")
93
+ ss.add_argument("--budget", type=int, default=None)
94
+ ss.add_argument("--roll-threshold", type=int, default=None,
95
+ help="override auto-rollup token threshold")
96
+ cx = sub.add_parser("ctx", help="context version chain: commit/log/show/restore")
97
+ cx.add_argument("op", choices=["commit", "log", "show", "restore"])
98
+ cx.add_argument("target", nargs="?", help="file path (commit) or version id")
99
+ cx.add_argument("-p", "--project", default="default")
100
+ cx.add_argument("-l", "--label", default="")
101
+ cx.add_argument("-o", "--output")
102
+ ev = sub.add_parser("evolve", help="self-evolve compressor genes")
103
+ ev.add_argument("--gens", type=int, default=8)
104
+ ev.add_argument("--pop", type=int, default=8)
105
+ ev.add_argument("--seed", type=int, default=42)
106
+
107
+ a = ap.parse_args(argv)
108
+
109
+ if a.cmd == "compress":
110
+ text = _read(a.input)
111
+ fl = FastLingua(a.mode, a.ratio, a.anchors,
112
+ auto=not a.no_auto, collapse=a.collapse)
113
+ comp, stats = fl.compress(text)
114
+ if a.adapt:
115
+ _adapt(fl, stats, a.ratio)
116
+ out = comp # engine already renders [F#] anchors when enabled
117
+ if a.output:
118
+ with open(a.output, "w", encoding="utf-8") as f:
119
+ f.write(out)
120
+ else:
121
+ print(out)
122
+ if a.save:
123
+ p = store.save(a.save, text, comp,
124
+ {"ratio": stats["ratio"], "mode": a.mode})
125
+ print("[stored] %s" % p, file=sys.stderr)
126
+
127
+ elif a.cmd == "estimate":
128
+ text = _read(a.input)
129
+ info = estimate_cost(text, a.model)
130
+ print("input : %6d tokens | $%.6f (%s)" % (info["tokens"], info["usd"], a.model))
131
+ if a.compress:
132
+ comp, _st = FastLingua("balanced", 0.5).compress(text)
133
+ after = estimate_cost(comp, a.model)
134
+ sv = savings(text, comp, a.model)
135
+ print("after : %6d tokens | $%.6f" % (after["tokens"], after["usd"]))
136
+ print("saved : %6d tokens | $%.6f | compressed to %.1f%%"
137
+ % (sv["tokens_saved"], sv["usd_saved"], sv["ratio"] * 100))
138
+
139
+ elif a.cmd == "store":
140
+ if a.op == "list":
141
+ rows = store.list_all()
142
+ if not rows:
143
+ print("(empty)")
144
+ for r in rows:
145
+ print("%-24s %7d -> %7d chars" % (r["name"], r["orig"], r["comp"]))
146
+ elif a.op == "get":
147
+ r = store.get(a.name or "")
148
+ print(r["text"] if r else "(not found)")
149
+ else:
150
+ print("deleted" if store.delete(a.name or "") else "(not found)")
151
+
152
+ elif a.cmd == "bench":
153
+ run_bench(getattr(a, "full", False))
154
+
155
+ elif a.cmd == "pack":
156
+ items = []
157
+ for src in a.inputs:
158
+ try:
159
+ items.append((os.path.basename(src), _read(src)))
160
+ except Exception as ex:
161
+ print("[skip] %s: %s" % (src, ex), file=sys.stderr)
162
+ if not items:
163
+ print("(no readable inputs)", file=sys.stderr)
164
+ return
165
+ res = pack(items, a.budget, a.ratio)
166
+ out = render(res)
167
+ if a.model:
168
+ from .estimate import estimate_cost
169
+ info = estimate_cost(out, a.model)
170
+ out += "\n=== cost: %d tokens | $%.6f (%s) ===" % (
171
+ info["tokens"], info["usd"], a.model)
172
+ if a.output:
173
+ with open(a.output, "w", encoding="utf-8") as f:
174
+ f.write(out)
175
+ else:
176
+ print(out)
177
+ if a.save:
178
+ pth = store.save(a.save, "".join(t for _, t in items), out,
179
+ {"budget": a.budget, "used": res["used"]})
180
+ print("[stored] %s" % pth, file=sys.stderr)
181
+
182
+ elif a.cmd == "selftest":
183
+ from .selftest import run
184
+ R, fails = run()
185
+ print("---")
186
+ print("TOTAL %d | PASS %d | FAIL %d" % (len(R), len(R) - len(fails), len(fails)))
187
+ if fails:
188
+ raise SystemExit(1)
189
+
190
+ elif a.cmd == "session":
191
+ if a.op == "new":
192
+ sess.new(a.name)
193
+ print("session '%s' ready" % a.name)
194
+ elif a.op == "push":
195
+ text = a.text if a.text is not None else (_read(a.file) if a.file else None)
196
+ if not text:
197
+ print("need -t TEXT or -f FILE", file=sys.stderr)
198
+ return
199
+ kw = {} if a.roll_threshold is None else {"roll_threshold": a.roll_threshold}
200
+ r = sess.push(a.name, a.role, text, **kw)
201
+ st = sess.stat(a.name)
202
+ print("pushed #%d (%s) | rolled %d | session: %d turns, ~%d tok"
203
+ % (r["seq"], a.role, r["rolled"], st["turns"], st["est_tokens"]))
204
+ elif a.op == "show":
205
+ out, used, dropped = sess.render(a.name, a.budget)
206
+ print(out)
207
+ else:
208
+ print(sess.stat(a.name))
209
+
210
+ elif a.cmd == "ctx":
211
+ if a.op == "commit":
212
+ if not a.target:
213
+ print("need file path", file=sys.stderr)
214
+ return
215
+ text = _read(a.target)
216
+ r = ctx_commit(a.project, text, a.label)
217
+ print("committed v%d [%s] %d -> %d chars (%.1f%%), stored %s"
218
+ % (r["id"], a.project, r["orig"], r["comp"],
219
+ r["ratio"] * 100, r["stored"]))
220
+ elif a.op == "log":
221
+ rows = ctx_log(a.project)
222
+ if not rows:
223
+ print("(empty)")
224
+ for r in rows:
225
+ print("v%-4d %-16s parent=%-4s %6d->%6d %s"
226
+ % (r["id"], r["label"] or "-", r["parent"] or "-",
227
+ r["orig"], r["comp"], "FULL" if r["full"] else "delta"))
228
+ else:
229
+ if not a.target or not a.target.isdigit():
230
+ print("need version id", file=sys.stderr)
231
+ return
232
+ text = ctx_show(int(a.target))
233
+ if text is None:
234
+ print("(version not found)", file=sys.stderr)
235
+ return
236
+ if a.output:
237
+ with open(a.output, "w", encoding="utf-8") as f:
238
+ f.write(text)
239
+ else:
240
+ print(text)
241
+
242
+ elif a.cmd == "evolve":
243
+ evolve(a.gens, a.pop, a.seed)
244
+
245
+ if __name__ == "__main__":
246
+ main()
File without changes
@@ -0,0 +1,346 @@
1
+ # -*- coding: utf-8 -*-
2
+ """FastLingua v2 - model-free context compressor.
3
+ Guarantees: entity retention (hard rules), determinism, no model needed."""
4
+ import json
5
+ import argparse, json, os, random, re, sys, time
6
+ from collections import Counter
7
+
8
+ ENTITY_PATTERNS = [
9
+ re.compile(r'https?://[A-Za-z0-9\-._~:/?#@!$&*+,;=%\[\]]+|www\.[A-Za-z0-9\-._~/%]+', re.I),
10
+ re.compile(r'\b[\w.+-]+@[\w-]+\.[\w.]+\b'),
11
+ re.compile(r'[A-Za-z]:\\[^\s,。;:!?、)(]+'),
12
+ re.compile(r'(?:/[\w.\-]+){2,}'),
13
+ re.compile(r'\b[\w\-]+(?:[/\\][\w\-]+)+\.(?:py|js|ts|json|md|txt|html|css|java|c|cpp|go|rs|sql|yaml|yml|bat|sh)\b', re.I),
14
+ re.compile(r'(?<![\w.\-])[\w\-]+\.(?:py|js|ts|json|md|txt|log|db|exe|bat|sh)(?![\w\-])', re.I),
15
+ re.compile(r'\d{4}[-/]\d{1,2}[-/]\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2})?)?'),
16
+ re.compile(r'\b\d{1,2}:\d{2}(?::\d{2})?\b'),
17
+ re.compile(r'\b[vV]?\d+(?:\.\d+)+[a-z]?\b'),
18
+ re.compile(r'\d+(?:\.\d+)?\s*(?:%|px|ms|[kKmMgGtT]?B|kb|mb|gb|tb|hz|万|亿|元|秒|毫秒|分钟|小时|天|周|月|年|个|人|次|倍)', re.I),
19
+ re.compile(r'(?<![A-Za-z0-9_])[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*)+\(\)'),
20
+ re.compile(r'(?<![A-Za-z0-9_])[A-Za-z_][\w]*(?:\.[A-Za-z_][\w]*){1,5}(?![A-Za-z0-9_])'),
21
+ re.compile(r'(?<![A-Za-z0-9_])[a-z_][a-z0-9]*(?:_[a-z0-9]+)+'),
22
+ re.compile(r'(?<![A-Za-z0-9_])[a-z]+(?:[A-Z][a-z0-9]+)+'),
23
+ re.compile(r'(?<![A-Za-z0-9_])[A-Z][A-Z0-9_]{2,}'),
24
+ re.compile(r'"[^"\n]{2,80}"'),
25
+ ]
26
+ # single-pass merged entity scanner (order = priority)
27
+ COMBINED_ENT_PAT = re.compile("|".join("(?:%s)" % pp.pattern for pp in ENTITY_PATTERNS))
28
+
29
+
30
+ KEEP_KEYWORDS = ['must', 'need', 'error', 'Error', 'ERROR', 'fail', 'crash',
31
+ 'TODO', 'FIXME', 'critical', 'root cause', 'fix',
32
+ '必须', '错误', '报错', '重要', '关键', '原因', '决定', '结论',
33
+ '总之', '综上', '总结', '建议', '注意', 'summary', 'recommend', 'conclusion']
34
+
35
+ CAUSAL_TERMS = ['because', 'therefore', 'thus', 'hence', 'root cause', 'due to',
36
+ 'as a result', '因为', '所以', '因此', '导致', '造成', '原因', '结果',
37
+ '由于', '从而', '进而', '以至于']
38
+ CODE_TERMS = ['error', 'exception', 'traceback', 'bug', 'fix', 'patch', 'deploy',
39
+ 'build', 'test', 'crash', 'stack', 'assert', 'timeout', 'rollback',
40
+ 'compile', 'runtime', '报错', '异常', '崩溃', '部署', '编译', '构建',
41
+ '测试', '回滚', '补丁', '上线']
42
+ FILLERS = ['basically', 'actually', 'literally', 'obviously', 'essentially',
43
+ 'you know', 'i mean', 'i think', 'i believe', 'in my opinion',
44
+ 'sort of', 'kind of', 'to be honest', 'needless to say',
45
+ '我觉得', '我认为', '说实话', '坦白说', '众所周知', '毫无疑问',
46
+ '基本上', '一般来说', '就是说', '也就是说',
47
+ '这样的话', '其实吧', '你知道吗', '我们可以看到']
48
+
49
+ STOPWORDS_EN = set('''a an the and or but nor so yet for of to in on at by with
50
+ from as is are was were be been being am do does did doing have has had having
51
+ will would shall should can could may might this that these those i you he she
52
+ it we they me him her us them my your his its our their there here when where
53
+ why how what which who whom whose if then than too very just also only own same
54
+ such no not now out up down off over under again further once about into through
55
+ during before after above below between both each few more most other some any
56
+ all'''.split())
57
+
58
+ STOPCHARS_ZH = set('的吗呢吧啊呀哦喔嘛么呗啦喽哇嘿哟欸诶之乎者矣焉哉')
59
+
60
+ ENT_RE = re.compile('|'.join('(?:%s)' % pp.pattern for pp in ENTITY_PATTERNS))
61
+ PH_RE = re.compile(r'\ue000\d+\ue001') # placeholder scanner (protected text)
62
+
63
+
64
+ GENES = {
65
+ 'dup_th': (0.55, 0.90),
66
+ 'ent_w': (1.0, 6.0),
67
+ 'cont_w': (0.4, 3.0),
68
+ 'keep_bonus': (2.0, 20.0),
69
+ 'causal_w': (0.5, 4.0),
70
+ 'code_w': (0.5, 4.0),
71
+ }
72
+
73
+ _EVOLVING = False
74
+
75
+ def _evolved_path():
76
+ return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
77
+ 'evolved.json')
78
+
79
+
80
+ class FastLingua:
81
+ MODES = ('light', 'balanced', 'aggressive')
82
+
83
+ def __init__(self, mode='balanced', target_ratio=0.5, anchors=False, auto=True, collapse=False):
84
+ self.mode = mode
85
+ self.target_ratio = target_ratio
86
+ self.anchors = anchors
87
+ self.auto = auto # statistical auto-discovery of fillers
88
+ self.collapse = collapse # exact-repeat phrase collapsing (opt-in)
89
+ # ---- evolvable genes ----
90
+ self.dup_th = 0.72
91
+ self.ent_w = 3.0
92
+ self.cont_w = 1.0
93
+ self.keep_bonus = 10.0
94
+ self.causal_w = 1.5
95
+ self.code_w = 1.5
96
+ self.evolved = self._load_genome()
97
+ self.last_auto = set() # words auto-discovered in last run
98
+ self._store = []
99
+
100
+ def _load_genome(self):
101
+ try:
102
+ with open(_evolved_path(), encoding='utf-8') as f:
103
+ evo = json.load(f)
104
+ n = 0
105
+ for k in GENES:
106
+ if k in evo:
107
+ setattr(self, k, float(evo[k]))
108
+ n += 1
109
+ return n > 0
110
+ except Exception:
111
+ return False
112
+
113
+ def _protect(self, text):
114
+ self._store = []
115
+ def sub(m):
116
+ self._store.append(m.group(0))
117
+ return '\uE000%d\uE001' % (len(self._store) - 1)
118
+ text = COMBINED_ENT_PAT.sub(sub, text)
119
+ return text
120
+
121
+ def _validate(self, orig, comp):
122
+ return _instance_validate(self, orig, comp)
123
+
124
+ def _restore(self, text):
125
+ def sub(m):
126
+ i = int(m.group(1))
127
+ return self._store[i] if i < len(self._store) else m.group(0)
128
+ return re.sub('\uE000(\\d+)\uE001', sub, text)
129
+
130
+ @staticmethod
131
+ def _split_sentences(text):
132
+ parts = re.split(r'(?<=[。!?!?;;])|\n'
133
+ r'|(?<=[A-Za-z0-9)\]”])\.\s+(?=[A-Z\u4e00-\u9fff])', text)
134
+ parts = [p.strip() for p in parts if p and p.strip()]
135
+ abbrev = {'com', 'net', 'org', 'gov', 'edu', 'io', 'inc', 'ltd', 'co',
136
+ 'vs', 'etc', 'fig', 'no', 'dr', 'mr', 'mrs', 'ms', 'st',
137
+ 'jr', 'sr', 'eg', 'ie'}
138
+ merged = []
139
+ for p in parts:
140
+ if merged:
141
+ words = merged[-1].split()
142
+ w = re.sub(r'[^A-Za-z]', '', words[-1]) if words else ''
143
+ if w in abbrev or (len(w) <= 2 and w.isalpha() and w.islower()):
144
+ merged[-1] = merged[-1] + ' ' + p
145
+ continue
146
+ merged.append(p)
147
+ # long-line chunking: punctuation-free mega-lines (raw dumps) can
148
+ # never be pruned as one giant sentence; split into ~500-char pieces
149
+ chunked = []
150
+ for p in merged:
151
+ if len(p) > 2000:
152
+ pieces = []
153
+ for w in p.split(' '):
154
+ while len(w) > 500: # spaceless mega-word: hard slice
155
+ pieces.append(w[:500])
156
+ w = w[500:]
157
+ pieces.append(w)
158
+ buf = ''
159
+ for w in pieces:
160
+ if buf and len(buf) + len(w) + 1 > 500:
161
+ chunked.append(buf)
162
+ buf = w
163
+ else:
164
+ buf = (buf + ' ' + w) if buf else w
165
+ if buf:
166
+ chunked.append(buf)
167
+ else:
168
+ chunked.append(p)
169
+ return chunked
170
+
171
+ @staticmethod
172
+ def _sig(s):
173
+ s = PH_RE.sub('', s)
174
+ words = set(w.lower() for w in re.findall(r'[A-Za-z]{2,}|\d+', s))
175
+ if words:
176
+ return words
177
+ chars = [c for c in s if '\u4e00' <= c <= '\u9fff']
178
+ return set('%s%s' % b for b in zip(chars, chars[1:]))
179
+
180
+ def _dedupe(self, sentences):
181
+ kept, sigs = [], []
182
+ for s in sentences:
183
+ sig = self._sig(s)
184
+ if not sig or PH_RE.search(s):
185
+ kept.append(s)
186
+ continue
187
+ dup = any(len(sig & p) / max(len(sig | p), 1) > self.dup_th for p in sigs[-12:])
188
+ if not dup:
189
+ kept.append(s)
190
+ sigs.append(sig)
191
+ return kept
192
+
193
+ def _density(self, s):
194
+ if not s:
195
+ return 0.0
196
+ ent = len(PH_RE.findall(s))
197
+ words = len(re.findall(r'[A-Za-z]{2,}|[\u4e00-\u9fff]', s))
198
+ keep = any(k in s for k in KEEP_KEYWORDS)
199
+ causal = sum(1 for k in CAUSAL_TERMS if k in s)
200
+ code = sum(1 for k in CODE_TERMS if k in s)
201
+ base = (ent * self.ent_w + words * self.cont_w
202
+ + causal * self.causal_w + code * self.code_w)
203
+ return base / max(len(s), 1) * (self.keep_bonus if keep else 1.0)
204
+
205
+ def _auto_filler_words(self, text):
206
+ """Statistical discovery: frequent short lowercase words = likely filler.
207
+ Guards: never touch Capitalized words (proper nouns), len<=7, freq>=4."""
208
+ tokens = re.findall(r'(?<![\w\-])[A-Za-z]+(?![\w\-])', PH_RE.sub(' ', text))
209
+ total = len(tokens)
210
+ if total < 60:
211
+ return set()
212
+ cnt = Counter(w.lower() for w in tokens)
213
+ need = max(4, total // 100)
214
+ found = set()
215
+ for w, c in cnt.items():
216
+ if c < need or len(w) > 7 or w in STOPWORDS_EN:
217
+ continue
218
+ # proper-noun guard: capitalized occurrence anywhere -> skip
219
+ if re.search(r'(?<![\w\-])' + re.escape(w.capitalize()) + r'(?![\w\-])', text):
220
+ continue
221
+ found.add(w)
222
+ return found
223
+
224
+ @staticmethod
225
+ def _collapse_repeats(s, min_count=2, long_len=16):
226
+ """Collapse EXACT repeated phrases (zero info loss).
227
+ Eligible: appears >=3 times, or >=2 times when phrase is long (>=16 chars)."""
228
+ changed = True
229
+ while changed:
230
+ changed = False
231
+ L = len(s)
232
+ max_n = min(40, L // max(min_count, 2))
233
+ for n in range(max_n, 5, -1):
234
+ seen = {}
235
+ for i in range(L - n + 1):
236
+ if i > 0 and s[i - 1] not in ' \n':
237
+ continue
238
+ j = i + n
239
+ if j < L and s[j] not in ' \n.,;:!?)':
240
+ continue
241
+ seen.setdefault(s[i:i + n], []).append(i)
242
+ hit = None
243
+ for g, idxs in seen.items():
244
+ if '\n' in g or '\ue000' in g or '\ue001' in g:
245
+ continue
246
+ ok = len(idxs) >= 3 or (len(idxs) >= min_count and n >= long_len)
247
+ if ok:
248
+ hit = (g, idxs)
249
+ break
250
+ if hit:
251
+ g, idxs = hit
252
+ for i in reversed(idxs[1:]):
253
+ s = s[:i] + s[i + n:]
254
+ changed = True
255
+ break
256
+ return s
257
+
258
+ def compress(self, text):
259
+ t0 = time.perf_counter()
260
+ orig_len = len(text)
261
+ has_zh = bool(re.search('[\u4e00-\u9fff]', text))
262
+ orig_text = text
263
+ text = self._protect(text)
264
+ n_ent = len(self._store)
265
+ for f in FILLERS:
266
+ text = re.sub(re.escape(f), '', text, flags=re.I)
267
+ sents = self._dedupe(self._split_sentences(text))
268
+ if self.mode in ('balanced', 'aggressive'):
269
+ # hyphen-aware: never split compounds like in-memory / state-of-the-art
270
+ def prune(m):
271
+ w = m.group(0)
272
+ return '' if w.lower() in STOPWORDS_EN else w
273
+ sents = [re.sub(r'(?<![\w\-])[A-Za-z]+(?![\w\-])', prune, s) for s in sents]
274
+ if self.mode == 'aggressive':
275
+ def prune_zh(m):
276
+ return ''.join(c for c in m.group(0) if c not in STOPCHARS_ZH)
277
+ sents = [re.sub(r'[\u4e00-\u9fff]+', prune_zh, s) for s in sents]
278
+ if self.auto and self.mode != 'light':
279
+ extra = self._auto_filler_words(text)
280
+ self.last_auto = extra
281
+ if extra:
282
+ alt = '|'.join(re.escape(w) for w in sorted(extra, key=len, reverse=True))
283
+ apat = re.compile(r'(?<![\w\-])(?:%s)(?![\w\-])' % alt, re.I)
284
+ sents = [apat.sub('', x) for x in sents]
285
+ if self.collapse:
286
+ joined = self._collapse_repeats('\n'.join(sents))
287
+ sents = [x for x in joined.split('\n') if x.strip()]
288
+ cur = sum(len(s) for s in sents)
289
+ budget = max(int(orig_len * self.target_ratio), 1)
290
+ if cur > budget and self.mode != 'light':
291
+ order = sorted(range(len(sents)), key=lambda i: self._density(sents[i]))
292
+ alive = [True] * len(sents)
293
+ remaining = cur
294
+ for i in order:
295
+ if remaining <= budget:
296
+ break
297
+ if i == 0 or i == len(sents) - 1 or '?' in sents[i] or '?' in sents[i]:
298
+ continue
299
+ # HARD GUARD: never drop a sentence carrying protected entities
300
+ if PH_RE.search(sents[i]):
301
+ continue
302
+ if any(k in sents[i] for k in KEEP_KEYWORDS):
303
+ continue
304
+ remaining -= len(sents[i])
305
+ alive[i] = False
306
+ sents = [s for s, a in zip(sents, alive) if a]
307
+ result = ''.join(sents) if has_zh else ' '.join(sents)
308
+ result = self._restore(result)
309
+ result = re.sub(r'[ \t]{2,}', ' ', result)
310
+ result = re.sub(r'\s+([.,;:!?)\]])', r'\1', result)
311
+ result = result.strip()
312
+ # ---- self-validation with graceful degradation ----
313
+ problems = self._validate(orig_text, result)
314
+ if problems and self.mode != 'light':
315
+ fb = FastLingua('light', min(self.target_ratio * 1.6, 0.95),
316
+ auto=self.auto, collapse=False)
317
+ alt, _st2 = fb.compress(text)
318
+ if not fb._validate(orig_text, alt):
319
+ result = alt
320
+ n_ent = len(fb._store)
321
+ elapsed = time.perf_counter() - t0
322
+ stats = {'orig': orig_len, 'comp': len(result),
323
+ 'ratio': round(len(result) / max(orig_len, 1), 3),
324
+ 'entities': n_ent, 'ms': round(elapsed * 1000, 2)}
325
+ if not result.strip() and orig_text.strip():
326
+ keep = [sn for sn in self._split_sentences(orig_text) if sn.strip()][:2]
327
+ result = ' '.join(keep) if keep else orig_text.strip()[:200]
328
+ if self.anchors:
329
+ result = '\n'.join('[F%d] %s' % (i + 1, f)
330
+ for i, f in enumerate(self._split_sentences(result)))
331
+ return result, stats
332
+
333
+
334
+ def _instance_validate(fl, orig, comp):
335
+ """Post-compression validation: entities + keep-keywords retention."""
336
+ problems = []
337
+ raw = sorted(set(ENT_RE.findall(orig)))
338
+ raw = [e for e in raw if not any(e != o and e in o for o in raw)]
339
+ miss = [e for e in raw if e not in comp]
340
+ if miss:
341
+ problems.append('entities:' + ','.join(miss[:3]))
342
+ ko = sum(1 for k in KEEP_KEYWORDS if k in orig)
343
+ kc = sum(1 for k in KEEP_KEYWORDS if k in comp)
344
+ if ko and kc < ko:
345
+ problems.append('keywords %d/%d' % (kc, ko))
346
+ return problems