Crownelius commited on
Commit
2646a31
·
verified ·
1 Parent(s): f1f6b4a

Add matchmaker 1B training pipeline

Browse files
.gitignore CHANGED
@@ -17,6 +17,11 @@ yarn-debug.log*
17
  yarn-error.log*
18
  pnpm-debug.log*
19
 
 
 
 
 
 
20
  # Backend build
21
  backend/dist/
22
 
@@ -40,3 +45,8 @@ frontend/ios/Flutter/Flutter.podspec
40
  # Supabase
41
  .branches/
42
  .temp/
 
 
 
 
 
 
17
  yarn-error.log*
18
  pnpm-debug.log*
19
 
20
+ # Python
21
+ __pycache__/
22
+ **/__pycache__/
23
+ *.pyc
24
+
25
  # Backend build
26
  backend/dist/
27
 
 
45
  # Supabase
46
  .branches/
47
  .temp/
48
+
49
+ # Training artifacts may contain profile-derived data
50
+ training/outputs/
51
+ training/.cache/
52
+ wandb/
README.md CHANGED
@@ -21,8 +21,9 @@ Premium AI dating platform powered by OpenClaw.
21
  - `backend/supabase/migrations/` PostgreSQL schema, RLS, pgvector matching RPC.
22
  - `shared/` full compatibility questionnaire and shared TypeScript contracts.
23
  - `artifacts/` OpenClaw prompt and response schema.
24
- - `docs/` setup, API, and deployment notes.
25
- - `app.py` Gradio/Hugging Face Space adapter.
 
26
 
27
  ## Gradio Space
28
 
@@ -58,7 +59,21 @@ $env:DATACLAW_ALLOW_PROFILE_DECRYPT="1"
58
  python tools/dataclaw_profile_vault.py encrypted-profile.jsonl.enc --output decrypted.jsonl --i-understand-raw-profile-output
59
  ```
60
 
61
- Do not expose decrypted JSONL through the Space UI or logs.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
 
63
  ## Backend
64
 
 
21
  - `backend/supabase/migrations/` PostgreSQL schema, RLS, pgvector matching RPC.
22
  - `shared/` full compatibility questionnaire and shared TypeScript contracts.
23
  - `artifacts/` OpenClaw prompt and response schema.
24
+ - `docs/` setup, API, and deployment notes.
25
+ - `app.py` Gradio/Hugging Face Space adapter.
26
+ - `training/` privacy-preserving matchmaker model training pipeline.
27
 
28
  ## Gradio Space
29
 
 
59
  python tools/dataclaw_profile_vault.py encrypted-profile.jsonl.enc --output decrypted.jsonl --i-understand-raw-profile-output
60
  ```
61
 
62
+ Do not expose decrypted JSONL through the Space UI or logs.
63
+
64
+ ## Matchmaker Training
65
+
66
+ The training pipeline builds de-identified pairwise examples from the encrypted matchmaker table and trains a small LoRA matchmaker model.
67
+
68
+ ```powershell
69
+ $env:MATCHMAKER_TRAINING_ACK="1"
70
+ python training/prepare_dataset.py `
71
+ --input training/examples/synthetic_profiles.jsonl `
72
+ --input-format plain-profiles `
73
+ --out-dir training/outputs/datasets/synthetic
74
+ ```
75
+
76
+ See `training/README.md` for real Dataclaw table prep, 1B LoRA training, prediction, and evaluation.
77
 
78
  ## Backend
79
 
training/README.md ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # loveGPT Matchmaker 1B Training Pipeline
2
+
3
+ This pipeline trains a small instruction model to score, weight, and compare de-identified dating profiles for the loveGPT matchmaker.
4
+
5
+ The model is not trained to reveal people. It is trained to return a structured compatibility judgment from two profile views:
6
+
7
+ ```json
8
+ {
9
+ "compatibility": 72.4,
10
+ "label": "candidate",
11
+ "invite_to_speed_date": true,
12
+ "dimensions": [
13
+ {"id": "values_future", "score": 81.0, "weight": 0.18}
14
+ ],
15
+ "rationale": ["Strongest signal: Values and future direction at 81.0%."]
16
+ }
17
+ ```
18
+
19
+ ## Privacy Rules
20
+
21
+ - The Space UI never exposes raw JSONL.
22
+ - Dataset preparation requires `MATCHMAKER_TRAINING_ACK=1`.
23
+ - Encrypted matchmaker rows are decrypted only by the training prep script and immediately converted into a de-identified training view.
24
+ - Training examples remove `userId`, `username`, `profileId`, display name, exact age, exact location, contact details, URLs, emails, phone numbers, and handles.
25
+ - Do not commit `training/outputs/`; it may contain sensitive profile-derived text.
26
+
27
+ ## Base Model
28
+
29
+ Default: `meta-llama/Llama-3.2-1B-Instruct`
30
+
31
+ Good alternatives:
32
+
33
+ - `TinyLlama/TinyLlama-1.1B-Chat-v1.0` for very low-resource experiments.
34
+ - `HuggingFaceTB/SmolLM2-1.7B-Instruct` if a slightly larger small model is acceptable.
35
+ - `Qwen/Qwen2.5-1.5B-Instruct` if you want stronger JSON behavior and are okay using Qwen-family models.
36
+
37
+ ## Install
38
+
39
+ ```powershell
40
+ python -m pip install -r training/requirements-training.txt
41
+ ```
42
+
43
+ For QLoRA on Windows, `bitsandbytes` may not be available. Train on Linux/RunPod/Hugging Face GPU for the normal 4-bit path.
44
+
45
+ ## Smoke Test With Synthetic Profiles
46
+
47
+ ```powershell
48
+ $env:MATCHMAKER_TRAINING_ACK="1"
49
+ python training/prepare_dataset.py `
50
+ --input training/examples/synthetic_profiles.jsonl `
51
+ --input-format plain-profiles `
52
+ --out-dir training/outputs/datasets/synthetic
53
+
54
+ python training/evaluate_matchmaker.py `
55
+ --dataset training/outputs/datasets/synthetic/eval.jsonl
56
+ ```
57
+
58
+ ## Prepare Real Dataclaw Data
59
+
60
+ Use the encrypted matchmaker table, not raw user exports.
61
+
62
+ ```powershell
63
+ $env:MATCHMAKER_TABLE_KEY="your-space-secret"
64
+ $env:MATCHMAKER_TRAINING_ACK="1"
65
+
66
+ python training/prepare_dataset.py `
67
+ --input C:\path\to\lovegpt-matchmaker-table.jsonl.enc `
68
+ --input-format encrypted-matchmaker `
69
+ --out-dir training/outputs/datasets/matchmaker `
70
+ --max-pairs 50000
71
+ ```
72
+
73
+ If `MATCHMAKER_TABLE_KEY` is not set, the prep script can derive the table key from `DATACLAW_PROFILE_KEY`, matching the Space behavior.
74
+
75
+ Optional feedback override file:
76
+
77
+ ```json
78
+ {"profileAId":"profile-a","profileBId":"profile-b","compatibility":88,"label":"high_match","invite_to_speed_date":true,"rationale":["Both users opted into contact exchange after the speed-date."]}
79
+ ```
80
+
81
+ Use it like this:
82
+
83
+ ```powershell
84
+ python training/prepare_dataset.py `
85
+ --input C:\path\to\lovegpt-matchmaker-table.jsonl.enc `
86
+ --input-format encrypted-matchmaker `
87
+ --feedback C:\path\to\matchmaker-feedback.jsonl `
88
+ --out-dir training/outputs/datasets/matchmaker
89
+ ```
90
+
91
+ ## Train LoRA
92
+
93
+ ```powershell
94
+ python training/train_lora.py `
95
+ --config training/config/matchmaker_1b_lora.yaml
96
+ ```
97
+
98
+ Output adapter:
99
+
100
+ ```text
101
+ training/outputs/adapters/matchmaker-1b-lora
102
+ ```
103
+
104
+ ## Predict And Evaluate
105
+
106
+ ```powershell
107
+ python training/predict.py `
108
+ --model meta-llama/Llama-3.2-1B-Instruct `
109
+ --adapter training/outputs/adapters/matchmaker-1b-lora `
110
+ --input training/outputs/datasets/matchmaker/test.jsonl `
111
+ --output training/outputs/predictions/matchmaker-test.jsonl
112
+
113
+ python training/evaluate_matchmaker.py `
114
+ --dataset training/outputs/datasets/matchmaker/test.jsonl `
115
+ --predictions training/outputs/predictions/matchmaker-test.jsonl
116
+ ```
117
+
118
+ Metrics:
119
+
120
+ - `validJsonRate`: model follows the strict output contract.
121
+ - `compatibilityMae`: average absolute error against the teacher/human target score.
122
+ - `inviteAccuracy`: whether the model agrees on opening a speed-date.
123
+
124
+ ## Improvement Loop
125
+
126
+ 1. Start with `strict-v1` teacher labels from the current matchmaker harness.
127
+ 2. Add human feedback later from mutual exchange outcomes, post-chat ratings, safety reports, and blocks.
128
+ 3. Weight feedback labels above teacher labels once enough data exists.
129
+ 4. Keep the deterministic harness as a fallback and safety gate.
130
+ 5. Only let the trained model propose scores; final room creation should still require hard privacy/safety checks.
training/config/matchmaker_1b_lora.yaml ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model:
2
+ base_model: meta-llama/Llama-3.2-1B-Instruct
3
+ trust_remote_code: false
4
+ load_in_4bit: true
5
+ device_map: auto
6
+
7
+ lora:
8
+ r: 32
9
+ alpha: 64
10
+ dropout: 0.05
11
+ target_modules:
12
+ - q_proj
13
+ - k_proj
14
+ - v_proj
15
+ - o_proj
16
+ - gate_proj
17
+ - up_proj
18
+ - down_proj
19
+
20
+ data:
21
+ train_file: training/outputs/datasets/matchmaker/train.jsonl
22
+ eval_file: training/outputs/datasets/matchmaker/eval.jsonl
23
+
24
+ training:
25
+ output_dir: training/outputs/runs/matchmaker-1b-lora
26
+ adapter_output_dir: training/outputs/adapters/matchmaker-1b-lora
27
+ max_seq_length: 4096
28
+ per_device_train_batch_size: 1
29
+ per_device_eval_batch_size: 1
30
+ gradient_accumulation_steps: 16
31
+ learning_rate: 0.00015
32
+ num_train_epochs: 2
33
+ warmup_ratio: 0.05
34
+ logging_steps: 10
35
+ eval_steps: 100
36
+ save_steps: 100
37
+ save_total_limit: 2
38
+ bf16: true
39
+ fp16: false
40
+ packing: false
41
+ report_to: []
training/evaluate_matchmaker.py ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+ from statistics import mean
8
+ from typing import Any
9
+
10
+
11
+ def load_jsonl(path: Path) -> list[dict[str, Any]]:
12
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
13
+
14
+
15
+ def parse_prediction(row: dict[str, Any]) -> dict[str, Any] | None:
16
+ text = row.get("predictionText", "")
17
+ if isinstance(text, dict):
18
+ return text
19
+ start = str(text).find("{")
20
+ end = str(text).rfind("}")
21
+ if start < 0 or end <= start:
22
+ return None
23
+ try:
24
+ parsed = json.loads(str(text)[start : end + 1])
25
+ except json.JSONDecodeError:
26
+ return None
27
+ return parsed if isinstance(parsed, dict) else None
28
+
29
+
30
+ def invite(value: dict[str, Any]) -> bool:
31
+ return bool(value.get("invite_to_speed_date") or value.get("passed"))
32
+
33
+
34
+ def main() -> None:
35
+ parser = argparse.ArgumentParser(description="Evaluate matchmaker predictions against teacher or feedback labels.")
36
+ parser.add_argument("--dataset", type=Path, required=True, help="Test/eval JSONL produced by prepare_dataset.py.")
37
+ parser.add_argument("--predictions", type=Path, help="Prediction JSONL produced by predict.py.")
38
+ args = parser.parse_args()
39
+
40
+ dataset = {row["pairHash"]: row for row in load_jsonl(args.dataset)}
41
+ if not args.predictions:
42
+ scores = [float(row["target"]["compatibility"]) for row in dataset.values()]
43
+ labels = {row["target"]["label"] for row in dataset.values()}
44
+ print(
45
+ json.dumps(
46
+ {
47
+ "pairs": len(dataset),
48
+ "meanTeacherCompatibility": round(mean(scores), 3) if scores else 0,
49
+ "positiveRate": round(sum(invite(row["target"]) for row in dataset.values()) / max(1, len(dataset)), 4),
50
+ "labels": sorted(labels),
51
+ },
52
+ indent=2,
53
+ sort_keys=True,
54
+ )
55
+ )
56
+ return
57
+
58
+ pred_rows = load_jsonl(args.predictions)
59
+ mae_values: list[float] = []
60
+ exact_invite = 0
61
+ valid_json = 0
62
+ evaluated = 0
63
+ for pred_row in pred_rows:
64
+ target_row = dataset.get(pred_row["pairHash"])
65
+ if not target_row:
66
+ continue
67
+ evaluated += 1
68
+ parsed = parse_prediction(pred_row)
69
+ if not parsed:
70
+ continue
71
+ valid_json += 1
72
+ target_score = float(target_row["target"]["compatibility"])
73
+ pred_score = float(parsed.get("compatibility", math.nan))
74
+ if not math.isnan(pred_score):
75
+ mae_values.append(abs(pred_score - target_score))
76
+ exact_invite += int(invite(parsed) == invite(target_row["target"]))
77
+
78
+ print(
79
+ json.dumps(
80
+ {
81
+ "evaluated": evaluated,
82
+ "validJsonRate": round(valid_json / max(1, evaluated), 4),
83
+ "compatibilityMae": round(mean(mae_values), 3) if mae_values else None,
84
+ "inviteAccuracy": round(exact_invite / max(1, evaluated), 4),
85
+ },
86
+ indent=2,
87
+ sort_keys=True,
88
+ )
89
+ )
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
training/examples/synthetic_profiles.jsonl ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {"source":"synthetic","profileId":"synthetic-a","profileComplete":true,"profile":{"display_name":"Synthetic A","age":"31","location":"Hidden","intent":"Long-term relationship"},"answers":{"q1":{"answer":"Honesty, warmth, loyalty, emotional courage, and building a thoughtful life through daily follow-through.","dealbreakerSeverity":null},"q2":{"answer":"A calm home, meaningful work, shared rituals, and enough ambition to keep growing without burning out.","dealbreakerSeverity":null},"q6":{"answer":"I bond through consistency, direct reassurance, and quiet dependability.","dealbreakerSeverity":null},"q11":{"answer":"I am captivated by kindness with backbone, dry wit, competence, and emotional steadiness.","dealbreakerSeverity":null},"q16":{"answer":"Poor hygiene, contempt toward service workers, and cruelty presented as humor are hard no signals.","dealbreakerSeverity":"high"},"q23":{"answer":"Conflict needs accountability, repair, and no disappearing for days.","dealbreakerSeverity":null},"q27":{"answer":"I prefer tidy spaces, planned weekends, health routines, and a grounded social life.","dealbreakerSeverity":null},"q37":{"answer":"Average build, active, comfortable with a soft but healthy body.","dealbreakerSeverity":null},"q38":{"answer":"I am attracted to average to athletic builds and am flexible when warmth and chemistry are there.","dealbreakerSeverity":null},"q39":{"answer":"Flexibility matters more than exact body type; hygiene and confidence carry a lot.","dealbreakerSeverity":null},"q40":{"answer":"I want my body to be seen kindly and realistically, not inspected like a checklist.","dealbreakerSeverity":null}},"captivatingTraits":["kindness with backbone","dry wit","competence"],"disgustTriggers":["poor hygiene","cruelty","contempt"]}
2
+ {"source":"synthetic","profileId":"synthetic-b","profileComplete":true,"profile":{"display_name":"Synthetic B","age":"33","location":"Hidden","intent":"Life partner"},"answers":{"q1":{"answer":"Kindness, honesty, accountability, curiosity, and a peaceful home life are non-negotiable.","dealbreakerSeverity":null},"q2":{"answer":"I want a warm home, good work, shared meals, travel sometimes, and a partner who repairs conflict quickly.","dealbreakerSeverity":null},"q6":{"answer":"I feel safest with steady communication and someone who means what they say.","dealbreakerSeverity":null},"q11":{"answer":"I admire emotionally mature people who are funny, grounded, capable, and affectionate.","dealbreakerSeverity":null},"q16":{"answer":"Cruel jokes, dirty living spaces, and chronic disrespect make attraction disappear.","dealbreakerSeverity":"high"},"q23":{"answer":"I can handle hard conversations if there is no stonewalling and both people apologize clearly.","dealbreakerSeverity":null},"q27":{"answer":"A clean home, moderate social life, exercise, and financial responsibility fit me best.","dealbreakerSeverity":null},"q37":{"answer":"Average active body type, not extremely lean, comfortable and healthy.","dealbreakerSeverity":null},"q38":{"answer":"I like average, soft, or athletic bodies and care more about energy and hygiene than a strict type.","dealbreakerSeverity":null},"q39":{"answer":"Very flexible if the person is confident, kind, and physically affectionate.","dealbreakerSeverity":null},"q40":{"answer":"I want attraction to feel mutual and generous.","dealbreakerSeverity":null}},"captivatingTraits":["emotionally mature","funny","grounded"],"disgustTriggers":["dirty living spaces","cruel jokes","disrespect"]}
3
+ {"source":"synthetic","profileId":"synthetic-c","profileComplete":true,"profile":{"display_name":"Synthetic C","age":"29","location":"Hidden","intent":"Intentional dating"},"answers":{"q1":{"answer":"Freedom, novelty, ambition, intensity, independence, and social momentum matter most.","dealbreakerSeverity":null},"q2":{"answer":"I want a highly mobile life with spontaneous travel, big career swings, and no fixed domestic routine.","dealbreakerSeverity":null},"q6":{"answer":"I need a lot of space and dislike frequent reassurance conversations.","dealbreakerSeverity":null},"q11":{"answer":"I am drawn to status, edge, boldness, nightlife charisma, and high intensity chemistry.","dealbreakerSeverity":null},"q16":{"answer":"Clinginess, routine, domestic predictability, and cautious people turn me off.","dealbreakerSeverity":"medium"},"q23":{"answer":"I prefer to cool off alone and revisit conflict later if it still matters.","dealbreakerSeverity":null},"q27":{"answer":"Late nights, travel, networking, and loose plans suit me best.","dealbreakerSeverity":null},"q37":{"answer":"Lean athletic body and high energy lifestyle.","dealbreakerSeverity":null},"q38":{"answer":"I strongly prefer very fit, polished, high-energy partners.","dealbreakerSeverity":null},"q39":{"answer":"I am not very flexible on physical type because attraction is immediate for me.","dealbreakerSeverity":null},"q40":{"answer":"I want a partner to appreciate discipline and presentation.","dealbreakerSeverity":null}},"captivatingTraits":["status","edge","boldness"],"disgustTriggers":["clinginess","routine","cautious people"]}
training/predict.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+
7
+
8
+ def load_jsonl(path: Path) -> list[dict]:
9
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
10
+
11
+
12
+ def main() -> None:
13
+ parser = argparse.ArgumentParser(description="Generate matchmaker predictions from a trained adapter.")
14
+ parser.add_argument("--model", required=True, help="Base model id or merged model path.")
15
+ parser.add_argument("--adapter", default="", help="Optional PEFT adapter path.")
16
+ parser.add_argument("--input", type=Path, required=True)
17
+ parser.add_argument("--output", type=Path, required=True)
18
+ parser.add_argument("--max-new-tokens", type=int, default=512)
19
+ args = parser.parse_args()
20
+
21
+ import torch
22
+ from peft import PeftModel
23
+ from transformers import AutoModelForCausalLM, AutoTokenizer
24
+
25
+ tokenizer = AutoTokenizer.from_pretrained(args.adapter or args.model, trust_remote_code=True)
26
+ if tokenizer.pad_token is None:
27
+ tokenizer.pad_token = tokenizer.eos_token
28
+ model = AutoModelForCausalLM.from_pretrained(
29
+ args.model,
30
+ device_map="auto",
31
+ torch_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
32
+ trust_remote_code=True,
33
+ )
34
+ if args.adapter:
35
+ model = PeftModel.from_pretrained(model, args.adapter)
36
+ model.eval()
37
+
38
+ rows = load_jsonl(args.input)
39
+ args.output.parent.mkdir(parents=True, exist_ok=True)
40
+ with args.output.open("w", encoding="utf-8") as handle:
41
+ for row in rows:
42
+ messages = row["messages"][:2]
43
+ prompt = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True) if getattr(tokenizer, "chat_template", None) else "\n\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages)
44
+ inputs = tokenizer(prompt, return_tensors="pt").to(model.device)
45
+ with torch.no_grad():
46
+ output = model.generate(
47
+ **inputs,
48
+ max_new_tokens=args.max_new_tokens,
49
+ temperature=0.0,
50
+ do_sample=False,
51
+ pad_token_id=tokenizer.eos_token_id,
52
+ )
53
+ text = tokenizer.decode(output[0][inputs["input_ids"].shape[1] :], skip_special_tokens=True)
54
+ handle.write(json.dumps({"pairHash": row["pairHash"], "target": row["target"], "predictionText": text}, ensure_ascii=False, sort_keys=True))
55
+ handle.write("\n")
56
+
57
+
58
+ if __name__ == "__main__":
59
+ main()
training/prepare_dataset.py ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import itertools
5
+ import json
6
+ import random
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ sys.path.insert(0, str(ROOT / "training" / "src"))
13
+
14
+ from lovegpt_matchmaker_training.crypto import decrypt_matchmaker_row
15
+ from lovegpt_matchmaker_training.features import compact_profile_for_prompt, sanitize_profile
16
+ from lovegpt_matchmaker_training.privacy import require_training_ack, stable_hash
17
+ from lovegpt_matchmaker_training.questions import load_questions
18
+ from lovegpt_matchmaker_training.teacher import label_from_score, strict_matchmaker_judgment
19
+
20
+
21
+ SYSTEM_PROMPT = """You are the loveGPT matchmaker scoring model.
22
+ Compare two de-identified dating profiles. Measure long-term values, emotional safety,
23
+ captivating traits, disgust filters, conflict repair, lifestyle fit, and body-attraction alignment.
24
+ Return one valid JSON object only. Never infer or reveal private identity details."""
25
+
26
+
27
+ def load_plain_profiles(path: Path) -> list[dict[str, Any]]:
28
+ profiles: list[dict[str, Any]] = []
29
+ for line in path.read_text(encoding="utf-8").splitlines():
30
+ if not line.strip():
31
+ continue
32
+ profiles.append(json.loads(line))
33
+ return profiles
34
+
35
+
36
+ def load_encrypted_matchmaker_profiles(path: Path) -> list[dict[str, Any]]:
37
+ latest: dict[str, dict[str, Any]] = {}
38
+ for line in path.read_text(encoding="utf-8").splitlines():
39
+ if not line.strip():
40
+ continue
41
+ payload = decrypt_matchmaker_row(line)
42
+ profile_id = str(payload.get("profileId") or "")
43
+ if not profile_id:
44
+ continue
45
+ previous = latest.get(profile_id)
46
+ if previous is None or str(payload.get("loggedAt", "")) >= str(previous.get("loggedAt", "")):
47
+ latest[profile_id] = payload
48
+ return list(latest.values())
49
+
50
+
51
+ def anonymized_profile_key(payload: dict[str, Any]) -> str:
52
+ raw = "::".join(
53
+ [
54
+ str(payload.get("profileId") or ""),
55
+ str(payload.get("userId") or ""),
56
+ json.dumps(payload.get("answers", {}), sort_keys=True, ensure_ascii=False)[:2048],
57
+ ]
58
+ )
59
+ return stable_hash(raw)
60
+
61
+
62
+ def load_feedback(path: Path | None, profiles: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
63
+ if not path:
64
+ return {}
65
+ by_profile_id = {str(profile.get("profileId") or ""): anonymized_profile_key(profile) for profile in profiles}
66
+ feedback: dict[str, dict[str, Any]] = {}
67
+ for line in path.read_text(encoding="utf-8").splitlines():
68
+ if not line.strip():
69
+ continue
70
+ row = json.loads(line)
71
+ pair_hash = row.get("pairHash")
72
+ if not pair_hash:
73
+ profile_a_id = str(row.get("profileAId") or "")
74
+ profile_b_id = str(row.get("profileBId") or "")
75
+ if profile_a_id not in by_profile_id or profile_b_id not in by_profile_id:
76
+ continue
77
+ pair_hash = stable_hash("::".join(sorted([by_profile_id[profile_a_id], by_profile_id[profile_b_id]])), salt="lovegpt-pair")
78
+ target: dict[str, Any] = {}
79
+ if "compatibility" in row:
80
+ target["compatibility"] = round(float(row["compatibility"]), 1)
81
+ if "label" in row:
82
+ target["label"] = str(row["label"])
83
+ if "invite_to_speed_date" in row:
84
+ target["invite_to_speed_date"] = bool(row["invite_to_speed_date"])
85
+ if "dimensions" in row and isinstance(row["dimensions"], list):
86
+ target["dimensions"] = row["dimensions"]
87
+ if "rationale" in row and isinstance(row["rationale"], list):
88
+ target["rationale"] = [str(item)[:240] for item in row["rationale"][:6]]
89
+ if target:
90
+ feedback[str(pair_hash)] = target
91
+ return feedback
92
+
93
+
94
+ def build_pair_example(profile_a: dict[str, Any], profile_b: dict[str, Any], split: str, feedback: dict[str, Any] | None = None) -> dict[str, Any]:
95
+ _, questions = load_questions()
96
+ safe_a = sanitize_profile(profile_a, questions)
97
+ safe_b = sanitize_profile(profile_b, questions)
98
+ judgment = strict_matchmaker_judgment(safe_a, safe_b)
99
+ score = float(judgment["compatibility"])
100
+ output = {
101
+ "compatibility": score,
102
+ "label": label_from_score(score),
103
+ "invite_to_speed_date": bool(judgment["passed"]),
104
+ "dimensions": judgment["dimensions"],
105
+ "rationale": dimension_rationale(judgment),
106
+ }
107
+ if feedback:
108
+ output = {
109
+ **output,
110
+ **feedback,
111
+ "compatibility": float(feedback.get("compatibility", output["compatibility"])),
112
+ "label": str(feedback.get("label", output["label"])),
113
+ "invite_to_speed_date": bool(feedback.get("invite_to_speed_date", output["invite_to_speed_date"])),
114
+ "dimensions": feedback.get("dimensions", output["dimensions"]),
115
+ "rationale": feedback.get("rationale", output["rationale"]),
116
+ }
117
+ user_payload = {
118
+ "profileA": compact_profile_for_prompt(safe_a),
119
+ "profileB": compact_profile_for_prompt(safe_b),
120
+ "task": "Score whether these two de-identified dating profiles should be introduced for a 20-minute speed-date.",
121
+ }
122
+ pair_id = "::".join(sorted([anonymized_profile_key(profile_a), anonymized_profile_key(profile_b)]))
123
+ return {
124
+ "schema": "lovegpt.matchmaker.sft_pair.v1",
125
+ "pairHash": stable_hash(pair_id, salt="lovegpt-pair"),
126
+ "split": split,
127
+ "targetSource": "feedback-v1" if feedback else "strict-v1",
128
+ "messages": [
129
+ {"role": "system", "content": SYSTEM_PROMPT},
130
+ {"role": "user", "content": json.dumps(user_payload, ensure_ascii=False, sort_keys=True)},
131
+ {"role": "assistant", "content": json.dumps(output, ensure_ascii=False, sort_keys=True)},
132
+ ],
133
+ "target": output,
134
+ }
135
+
136
+
137
+ def dimension_rationale(judgment: dict[str, Any]) -> list[str]:
138
+ ordered = sorted(judgment["dimensions"], key=lambda item: item["score"])
139
+ weakest = ordered[:2]
140
+ strongest = ordered[-2:]
141
+ notes = [
142
+ f"Strongest signal: {item['label']} at {item['score']}%."
143
+ for item in reversed(strongest)
144
+ ]
145
+ notes.extend(
146
+ f"Weakness to respect: {item['label']} at {item['score']}%."
147
+ for item in weakest
148
+ )
149
+ return notes
150
+
151
+
152
+ def split_for_pair(pair_hash: str, train_ratio: float, eval_ratio: float) -> str:
153
+ bucket = int(pair_hash[:8], 16) / 0xFFFFFFFF
154
+ if bucket < train_ratio:
155
+ return "train"
156
+ if bucket < train_ratio + eval_ratio:
157
+ return "eval"
158
+ return "test"
159
+
160
+
161
+ def write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None:
162
+ path.parent.mkdir(parents=True, exist_ok=True)
163
+ with path.open("w", encoding="utf-8") as handle:
164
+ for row in rows:
165
+ handle.write(json.dumps(row, ensure_ascii=False, sort_keys=True))
166
+ handle.write("\n")
167
+
168
+
169
+ def main() -> None:
170
+ parser = argparse.ArgumentParser(description="Prepare de-identified loveGPT matchmaker pairwise SFT data.")
171
+ parser.add_argument("--input", required=True, type=Path, help="Encrypted matchmaker JSONL or plain profile JSONL.")
172
+ parser.add_argument("--input-format", choices=["encrypted-matchmaker", "plain-profiles"], default="encrypted-matchmaker")
173
+ parser.add_argument("--out-dir", type=Path, default=Path("training/outputs/datasets/matchmaker"))
174
+ parser.add_argument("--max-pairs", type=int, default=20000)
175
+ parser.add_argument("--seed", type=int, default=17)
176
+ parser.add_argument("--train-ratio", type=float, default=0.82)
177
+ parser.add_argument("--eval-ratio", type=float, default=0.09)
178
+ parser.add_argument("--feedback", type=Path, help="Optional Dataclaw-only JSONL of pair labels that override teacher targets.")
179
+ args = parser.parse_args()
180
+
181
+ require_training_ack()
182
+ profiles = load_plain_profiles(args.input) if args.input_format == "plain-profiles" else load_encrypted_matchmaker_profiles(args.input)
183
+ profiles = [profile for profile in profiles if profile.get("profileComplete") or profile.get("answers")]
184
+ if len(profiles) < 2:
185
+ raise SystemExit("Need at least two profiles to build pairwise examples.")
186
+
187
+ pairs = list(itertools.combinations(profiles, 2))
188
+ random.Random(args.seed).shuffle(pairs)
189
+ pairs = pairs[: args.max_pairs]
190
+ feedback_by_pair = load_feedback(args.feedback, profiles)
191
+
192
+ rows: list[dict[str, Any]] = []
193
+ for profile_a, profile_b in pairs:
194
+ pair_hash = stable_hash("::".join(sorted([anonymized_profile_key(profile_a), anonymized_profile_key(profile_b)])), salt="lovegpt-pair")
195
+ rows.append(build_pair_example(profile_a, profile_b, split_for_pair(pair_hash, args.train_ratio, args.eval_ratio), feedback_by_pair.get(pair_hash)))
196
+
197
+ for split in ("train", "eval", "test"):
198
+ split_rows = [row for row in rows if row["split"] == split]
199
+ write_jsonl(args.out_dir / f"{split}.jsonl", split_rows)
200
+
201
+ manifest = {
202
+ "schema": "lovegpt.matchmaker.dataset_manifest.v1",
203
+ "profileCount": len(profiles),
204
+ "pairCount": len(rows),
205
+ "splits": {split: sum(1 for row in rows if row["split"] == split) for split in ("train", "eval", "test")},
206
+ "feedbackOverrides": sum(1 for row in rows if row.get("targetSource") == "feedback-v1"),
207
+ "inputFormat": args.input_format,
208
+ "privacy": "De-identified profile view: no user ids, usernames, names, exact ages, exact locations, contacts, or URLs.",
209
+ }
210
+ (args.out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, sort_keys=True), encoding="utf-8")
211
+ print(json.dumps(manifest, indent=2, sort_keys=True))
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
training/requirements-training.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ accelerate>=0.34
2
+ bitsandbytes>=0.43; platform_system != "Windows"
3
+ cryptography>=42,<47
4
+ datasets>=2.20
5
+ huggingface_hub>=0.33.5,<2
6
+ peft>=0.12
7
+ pyyaml>=6.0.2
8
+ scikit-learn>=1.5
9
+ torch>=2.3
10
+ transformers>=4.45
training/src/lovegpt_matchmaker_training/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Training utilities for the loveGPT matchmaker model."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
training/src/lovegpt_matchmaker_training/crypto.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ from typing import Any
9
+
10
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
11
+
12
+ MATCHMAKER_TABLE_SCHEMA = "opendatebase.matchmaker.table.v1"
13
+
14
+
15
+ def b64url_decode(value: str) -> bytes:
16
+ return base64.urlsafe_b64decode(value + ("=" * (-len(value) % 4)))
17
+
18
+
19
+ def canonical_json(value: Any) -> bytes:
20
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
21
+
22
+
23
+ def _dataclaw_profile_key() -> tuple[bytes, str]:
24
+ raw = os.getenv("DATACLAW_PROFILE_KEY", "").strip()
25
+ if not raw:
26
+ raise SystemExit("DATACLAW_PROFILE_KEY or MATCHMAKER_TABLE_KEY is required to decrypt encrypted matchmaker rows.")
27
+ try:
28
+ decoded = b64url_decode(raw)
29
+ except Exception:
30
+ decoded = b""
31
+ if len(decoded) >= 32:
32
+ return hashlib.sha256(decoded).digest(), "dataclaw-secret"
33
+ return hashlib.sha256(raw.encode("utf-8")).digest(), "dataclaw-secret"
34
+
35
+
36
+ def matchmaker_table_key() -> bytes:
37
+ raw = os.getenv("MATCHMAKER_TABLE_KEY", "").strip()
38
+ if raw:
39
+ try:
40
+ decoded = b64url_decode(raw)
41
+ except Exception:
42
+ decoded = b""
43
+ if len(decoded) >= 32:
44
+ return hashlib.sha256(decoded).digest()
45
+ return hashlib.sha256(raw.encode("utf-8")).digest()
46
+
47
+ key, _scope = _dataclaw_profile_key()
48
+ return hashlib.sha256(key + b"opendatebase-matchmaker-table").digest()
49
+
50
+
51
+ def scoped_key_id(key: bytes, scope: bytes) -> str:
52
+ return hmac.new(key, scope, hashlib.sha256).hexdigest()[:16]
53
+
54
+
55
+ def decrypt_matchmaker_row(row: dict[str, Any] | str) -> dict[str, Any]:
56
+ encrypted = json.loads(row) if isinstance(row, str) else row
57
+ if encrypted.get("schema") != MATCHMAKER_TABLE_SCHEMA:
58
+ raise ValueError("Unsupported matchmaker row schema.")
59
+ key = matchmaker_table_key()
60
+ expected_key_id = scoped_key_id(key, b"opendatebase-matchmaker-table")
61
+ if not hmac.compare_digest(str(encrypted.get("keyId", "")), expected_key_id):
62
+ raise ValueError("Matchmaker table key mismatch.")
63
+ plaintext = AESGCM(key).decrypt(
64
+ b64url_decode(str(encrypted["nonce"])),
65
+ b64url_decode(str(encrypted["ciphertext"])),
66
+ canonical_json(encrypted["aad"]),
67
+ )
68
+ return json.loads(plaintext.decode("utf-8"))
training/src/lovegpt_matchmaker_training/features.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .privacy import age_bucket, normalize_text, redact_contacts
6
+ from .questions import load_questions
7
+ from .schema import ProfilePayload, Question
8
+
9
+
10
+ SENSITIVE_PROFILE_KEYS = {
11
+ "display_name",
12
+ "name",
13
+ "full_name",
14
+ "location",
15
+ "email",
16
+ "phone",
17
+ "contact",
18
+ "handle",
19
+ }
20
+
21
+
22
+ def _answer_text(answer: Any) -> str:
23
+ if isinstance(answer, list):
24
+ return redact_contacts("; ".join(normalize_text(item) for item in answer))
25
+ return redact_contacts(normalize_text(answer))
26
+
27
+
28
+ def sanitize_profile(payload: ProfilePayload, questions: list[Question] | None = None) -> dict[str, Any]:
29
+ """Return the training-safe view of a questionnaire payload.
30
+
31
+ This intentionally excludes user ids, usernames, profile ids, exact location,
32
+ exact age, and display names. It keeps questionnaire content because that is
33
+ the signal the matchmaker must learn from.
34
+ """
35
+
36
+ _, all_questions = load_questions()
37
+ questions = questions or all_questions
38
+ answers = payload.get("answers", {}) if isinstance(payload.get("answers"), dict) else {}
39
+ profile = payload.get("profile", {}) if isinstance(payload.get("profile"), dict) else {}
40
+
41
+ sanitized_answers: dict[str, dict[str, Any]] = {}
42
+ for question in questions:
43
+ raw = answers.get(question.id)
44
+ if not isinstance(raw, dict):
45
+ continue
46
+ answer = _answer_text(raw.get("answer"))
47
+ if not answer:
48
+ continue
49
+ sanitized_answers[question.id] = {
50
+ "question": question.prompt,
51
+ "categoryId": question.category_id,
52
+ "tags": list(question.tags),
53
+ "answer": answer,
54
+ "dealbreakerSeverity": raw.get("dealbreakerSeverity"),
55
+ }
56
+
57
+ return {
58
+ "ageBucket": age_bucket(profile.get("age", "")),
59
+ "intent": redact_contacts(normalize_text(profile.get("intent", ""))) or "unknown",
60
+ "profileComplete": bool(payload.get("profileComplete")),
61
+ "answerCount": len(sanitized_answers),
62
+ "answers": sanitized_answers,
63
+ "captivatingTraits": [redact_contacts(normalize_text(item)) for item in payload.get("captivatingTraits", [])][:16],
64
+ "disgustTriggers": [redact_contacts(normalize_text(item)) for item in payload.get("disgustTriggers", [])][:16],
65
+ }
66
+
67
+
68
+ def compact_profile_for_prompt(profile: dict[str, Any], max_answer_chars: int = 420) -> dict[str, Any]:
69
+ answers = {}
70
+ for question_id, answer in profile.get("answers", {}).items():
71
+ text = normalize_text(answer.get("answer", ""))
72
+ answers[question_id] = {
73
+ "categoryId": answer.get("categoryId"),
74
+ "tags": answer.get("tags", []),
75
+ "answer": text[:max_answer_chars],
76
+ "dealbreakerSeverity": answer.get("dealbreakerSeverity"),
77
+ }
78
+ return {
79
+ "ageBucket": profile.get("ageBucket", "unknown"),
80
+ "intent": profile.get("intent", "unknown"),
81
+ "answerCount": profile.get("answerCount", len(answers)),
82
+ "answers": answers,
83
+ "captivatingTraits": profile.get("captivatingTraits", [])[:8],
84
+ "disgustTriggers": profile.get("disgustTriggers", [])[:8],
85
+ }
training/src/lovegpt_matchmaker_training/privacy.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import os
6
+ import re
7
+ from typing import Any
8
+
9
+ CONTACT_PATTERNS = [
10
+ re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE),
11
+ re.compile(r"\b(?:\+?1[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?)\d{3}[\s.-]?\d{4}\b"),
12
+ re.compile(r"\b(?:instagram|insta|ig|snapchat|telegram|discord|whatsapp|signal)\s*[:@]\s*[A-Za-z0-9_.-]+\b", re.IGNORECASE),
13
+ re.compile(r"\bhttps?://\S+\b", re.IGNORECASE),
14
+ ]
15
+
16
+
17
+ def normalize_text(value: Any) -> str:
18
+ return re.sub(r"\s+", " ", str(value or "").strip())
19
+
20
+
21
+ def redact_contacts(value: str) -> str:
22
+ text = normalize_text(value)
23
+ for pattern in CONTACT_PATTERNS:
24
+ text = pattern.sub("[redacted-contact]", text)
25
+ return text
26
+
27
+
28
+ def stable_hash(value: str, salt: str = "lovegpt-training") -> str:
29
+ digest = hmac.new(salt.encode("utf-8"), value.encode("utf-8"), hashlib.sha256).hexdigest()
30
+ return digest[:16]
31
+
32
+
33
+ def age_bucket(value: Any) -> str:
34
+ text = normalize_text(value)
35
+ match = re.search(r"\d{1,3}", text)
36
+ if not match:
37
+ return "unknown"
38
+ age = max(18, min(99, int(match.group(0))))
39
+ lower = (age // 5) * 5
40
+ upper = lower + 4
41
+ return f"{lower}-{upper}"
42
+
43
+
44
+ def require_training_ack() -> None:
45
+ if os.getenv("MATCHMAKER_TRAINING_ACK") != "1":
46
+ raise SystemExit(
47
+ "Set MATCHMAKER_TRAINING_ACK=1 before writing model-training data. "
48
+ "This acknowledges that Dataclaw-authorized profile text will be de-identified and used for training."
49
+ )
training/src/lovegpt_matchmaker_training/questions.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ from .schema import Question
7
+
8
+
9
+ def project_root() -> Path:
10
+ return Path(__file__).resolve().parents[3]
11
+
12
+
13
+ def load_questions(path: Path | None = None) -> tuple[list[dict], list[Question]]:
14
+ questions_path = path or project_root() / "shared" / "questions.json"
15
+ document = json.loads(questions_path.read_text(encoding="utf-8"))
16
+ questions = [
17
+ Question(
18
+ id=item["id"],
19
+ number=int(item["number"]),
20
+ category_id=item["categoryId"],
21
+ prompt=item["prompt"],
22
+ weight=float(item["weight"]),
23
+ tags=tuple(item.get("tags", [])),
24
+ )
25
+ for item in document["questions"]
26
+ ]
27
+ return list(document["categories"]), questions
training/src/lovegpt_matchmaker_training/schema.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Question:
9
+ id: str
10
+ number: int
11
+ category_id: str
12
+ prompt: str
13
+ weight: float
14
+ tags: tuple[str, ...]
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class HarnessDimension:
19
+ id: str
20
+ label: str
21
+ weight: float
22
+ tags: frozenset[str]
23
+
24
+
25
+ ProfilePayload = dict[str, Any]
26
+ PairExample = dict[str, Any]
training/src/lovegpt_matchmaker_training/teacher.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import math
4
+ import re
5
+ from typing import Any
6
+
7
+ from .questions import load_questions
8
+ from .schema import HarnessDimension, ProfilePayload
9
+
10
+ MATCHMAKER_HARNESS = [
11
+ HarnessDimension("values_future", "Values and future direction", 0.18, frozenset({"values", "future", "long_term_fit", "life_design"})),
12
+ HarnessDimension("emotional_safety", "Emotional safety and attachment", 0.16, frozenset({"emotional_safety", "attachment", "trust", "care", "support"})),
13
+ HarnessDimension("captivation", "Captivating traits and admiration", 0.14, frozenset({"captivating_traits", "attraction", "desire", "chemistry"})),
14
+ HarnessDimension("disgust_filters", "Disgust filters and aversions", 0.17, frozenset({"disgust", "hygiene", "hard_filter", "lifestyle_filters"})),
15
+ HarnessDimension("conflict_repair", "Conflict and repair", 0.13, frozenset({"conflict", "repair", "apology", "communication"})),
16
+ HarnessDimension("lifestyle", "Lifestyle and practical future", 0.10, frozenset({"lifestyle", "money", "family", "home", "health", "growth"})),
17
+ HarnessDimension("body_attraction", "Body-type attraction alignment", 0.12, frozenset({"body_type", "desired_body_type", "physical_attraction", "body_preference"})),
18
+ ]
19
+
20
+
21
+ def lexical_score(a: str, b: str) -> float:
22
+ a_terms = {term for term in re.findall(r"[a-z0-9]{3,}", a.lower())}
23
+ b_terms = {term for term in re.findall(r"[a-z0-9]{3,}", b.lower())}
24
+ if not a_terms or not b_terms:
25
+ return 0.0
26
+ return len(a_terms & b_terms) / math.sqrt(len(a_terms) * len(b_terms))
27
+
28
+
29
+ def answers_for_tags(payload: ProfilePayload, tags: frozenset[str]) -> str:
30
+ _, questions = load_questions()
31
+ answers = payload.get("answers", {})
32
+ chunks: list[str] = []
33
+ for question in questions:
34
+ if question.id not in answers or not (set(question.tags) & tags):
35
+ continue
36
+ answer = answers[question.id].get("answer", "")
37
+ if isinstance(answer, list):
38
+ chunks.extend(str(item) for item in answer)
39
+ else:
40
+ chunks.append(str(answer))
41
+ return " ".join(chunks)
42
+
43
+
44
+ def question_answer(payload: ProfilePayload, question_id: str) -> str:
45
+ answer = payload.get("answers", {}).get(question_id, {}).get("answer", "")
46
+ if isinstance(answer, list):
47
+ return " ".join(str(item) for item in answer)
48
+ return str(answer)
49
+
50
+
51
+ def open_preference_text(value: str) -> bool:
52
+ lowered = value.lower()
53
+ return any(term in lowered for term in ("open", "flexible", "range", "many", "varied", "not picky", "any", "all body"))
54
+
55
+
56
+ def body_alignment_score(a: ProfilePayload, b: ProfilePayload) -> float:
57
+ a_own = " ".join([question_answer(a, "q37"), question_answer(a, "q40")])
58
+ a_wants = " ".join([question_answer(a, "q38"), question_answer(a, "q39")])
59
+ b_own = " ".join([question_answer(b, "q37"), question_answer(b, "q40")])
60
+ b_wants = " ".join([question_answer(b, "q38"), question_answer(b, "q39")])
61
+ if not a_wants or not b_wants:
62
+ return 0.0
63
+ a_to_b = 0.75 if open_preference_text(a_wants) else lexical_score(a_wants, b_own)
64
+ b_to_a = 0.75 if open_preference_text(b_wants) else lexical_score(b_wants, a_own)
65
+ return max(0.0, min(1.0, (a_to_b + b_to_a) / 2))
66
+
67
+
68
+ def strict_matchmaker_judgment(a: ProfilePayload, b: ProfilePayload) -> dict[str, Any]:
69
+ dimensions: list[dict[str, Any]] = []
70
+ weighted = 0.0
71
+ for item in MATCHMAKER_HARNESS:
72
+ score = body_alignment_score(a, b) if item.id == "body_attraction" else lexical_score(answers_for_tags(a, item.tags), answers_for_tags(b, item.tags))
73
+ weighted += score * item.weight
74
+ dimensions.append(
75
+ {
76
+ "id": item.id,
77
+ "label": item.label,
78
+ "score": round(score * 100, 1),
79
+ "weight": item.weight,
80
+ }
81
+ )
82
+
83
+ body_score = next(row["score"] for row in dimensions if row["id"] == "body_attraction")
84
+ disgust_score = next(row["score"] for row in dimensions if row["id"] == "disgust_filters")
85
+ total = round(weighted * 100, 1)
86
+ passed = total >= 62 and body_score >= 20 and disgust_score >= 12
87
+ return {
88
+ "harness": "strict-v1-teacher",
89
+ "compatibility": total,
90
+ "passed": passed,
91
+ "dimensions": dimensions,
92
+ "requirements": {
93
+ "minimumCompatibility": 62,
94
+ "minimumBodyAlignment": 20,
95
+ "minimumDisgustAlignment": 12,
96
+ },
97
+ }
98
+
99
+
100
+ def label_from_score(score: float) -> str:
101
+ if score >= 78:
102
+ return "high_match"
103
+ if score >= 62:
104
+ return "candidate"
105
+ if score >= 42:
106
+ return "weak_match"
107
+ return "do_not_match"
training/train_lora.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ sys.path.insert(0, str(ROOT / "training" / "src"))
11
+
12
+
13
+ def load_config(path: Path) -> dict[str, Any]:
14
+ import yaml
15
+
16
+ return yaml.safe_load(path.read_text(encoding="utf-8"))
17
+
18
+
19
+ def load_jsonl(path: Path) -> list[dict[str, Any]]:
20
+ return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
21
+
22
+
23
+ def format_messages(tokenizer: Any, row: dict[str, Any]) -> str:
24
+ messages = row["messages"]
25
+ if getattr(tokenizer, "chat_template", None):
26
+ return tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=False)
27
+ return "\n\n".join(f"{msg['role'].upper()}: {msg['content']}" for msg in messages)
28
+
29
+
30
+ def main() -> None:
31
+ parser = argparse.ArgumentParser(description="Train a small loveGPT matchmaker LoRA adapter.")
32
+ parser.add_argument("--config", type=Path, default=Path("training/config/matchmaker_1b_lora.yaml"))
33
+ args = parser.parse_args()
34
+
35
+ import torch
36
+ from datasets import Dataset
37
+ from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
38
+ from transformers import AutoModelForCausalLM, AutoTokenizer, BitsAndBytesConfig, DataCollatorForLanguageModeling, Trainer, TrainingArguments
39
+
40
+ cfg = load_config(args.config)
41
+ model_name = cfg["model"]["base_model"]
42
+ train_rows = load_jsonl(Path(cfg["data"]["train_file"]))
43
+ eval_rows = load_jsonl(Path(cfg["data"]["eval_file"]))
44
+
45
+ tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=cfg["model"].get("trust_remote_code", False))
46
+ if tokenizer.pad_token is None:
47
+ tokenizer.pad_token = tokenizer.eos_token
48
+
49
+ train_dataset = Dataset.from_list([{"text": format_messages(tokenizer, row)} for row in train_rows])
50
+ eval_dataset = Dataset.from_list([{"text": format_messages(tokenizer, row)} for row in eval_rows])
51
+
52
+ quant_cfg = None
53
+ if cfg["model"].get("load_in_4bit", True):
54
+ quant_cfg = BitsAndBytesConfig(
55
+ load_in_4bit=True,
56
+ bnb_4bit_quant_type="nf4",
57
+ bnb_4bit_use_double_quant=True,
58
+ bnb_4bit_compute_dtype=torch.bfloat16 if torch.cuda.is_available() else torch.float32,
59
+ )
60
+
61
+ model = AutoModelForCausalLM.from_pretrained(
62
+ model_name,
63
+ quantization_config=quant_cfg,
64
+ device_map=cfg["model"].get("device_map", "auto"),
65
+ trust_remote_code=cfg["model"].get("trust_remote_code", False),
66
+ )
67
+ if quant_cfg is not None:
68
+ model = prepare_model_for_kbit_training(model)
69
+
70
+ lora = LoraConfig(
71
+ r=int(cfg["lora"]["r"]),
72
+ lora_alpha=int(cfg["lora"]["alpha"]),
73
+ lora_dropout=float(cfg["lora"]["dropout"]),
74
+ bias="none",
75
+ task_type="CAUSAL_LM",
76
+ target_modules=list(cfg["lora"]["target_modules"]),
77
+ )
78
+ model = get_peft_model(model, lora)
79
+
80
+ training_args = TrainingArguments(
81
+ output_dir=cfg["training"]["output_dir"],
82
+ per_device_train_batch_size=int(cfg["training"]["per_device_train_batch_size"]),
83
+ per_device_eval_batch_size=int(cfg["training"]["per_device_eval_batch_size"]),
84
+ gradient_accumulation_steps=int(cfg["training"]["gradient_accumulation_steps"]),
85
+ learning_rate=float(cfg["training"]["learning_rate"]),
86
+ num_train_epochs=float(cfg["training"]["num_train_epochs"]),
87
+ warmup_ratio=float(cfg["training"]["warmup_ratio"]),
88
+ logging_steps=int(cfg["training"]["logging_steps"]),
89
+ evaluation_strategy="steps",
90
+ eval_steps=int(cfg["training"]["eval_steps"]),
91
+ save_steps=int(cfg["training"]["save_steps"]),
92
+ save_total_limit=int(cfg["training"]["save_total_limit"]),
93
+ bf16=bool(cfg["training"].get("bf16", True)) and torch.cuda.is_available(),
94
+ fp16=bool(cfg["training"].get("fp16", False)) and torch.cuda.is_available(),
95
+ report_to=list(cfg["training"].get("report_to", [])),
96
+ remove_unused_columns=False,
97
+ )
98
+
99
+ max_seq_length = int(cfg["training"]["max_seq_length"])
100
+
101
+ def tokenize(batch: dict[str, list[str]]) -> dict[str, Any]:
102
+ return tokenizer(
103
+ batch["text"],
104
+ truncation=True,
105
+ max_length=max_seq_length,
106
+ padding=False,
107
+ )
108
+
109
+ tokenized_train = train_dataset.map(tokenize, batched=True, remove_columns=["text"])
110
+ tokenized_eval = eval_dataset.map(tokenize, batched=True, remove_columns=["text"])
111
+ collator = DataCollatorForLanguageModeling(tokenizer=tokenizer, mlm=False)
112
+
113
+ trainer = Trainer(
114
+ model=model,
115
+ args=training_args,
116
+ train_dataset=tokenized_train,
117
+ eval_dataset=tokenized_eval,
118
+ data_collator=collator,
119
+ )
120
+ trainer.train()
121
+ trainer.save_model(cfg["training"]["adapter_output_dir"])
122
+ tokenizer.save_pretrained(cfg["training"]["adapter_output_dir"])
123
+ print(f"Saved adapter to {cfg['training']['adapter_output_dir']}")
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()