Add Natural Memory architecture and tooling

This commit is contained in:
WpyQwq
2026-09-05 08:53:41 +08:00
parent 0acf8b06ee
commit 516351f0b5
56 changed files with 18319 additions and 0 deletions
+747
View File
@@ -0,0 +1,747 @@
"""Train the small internal query-to-text-memory retrieval head."""
from __future__ import annotations
import argparse
import json
import random
from pathlib import Path
from typing import Optional
import torch
import torch.nn.functional as F
from .qwen_integration import load_memory_config, load_qwen_dynamic, load_tokenizer
ATTRIBUTES = {
"name": {
"facts": (
"我的名字是{value}。",
"请记住,我叫{value}。",
"用户姓名记录为{value}。",
"我叫{value}",
"我是{value}",
"叫我{value}就行",
"姓名:{value}",
"用户叫{value}",
),
"queries": (
"我叫什么名字?",
"请告诉我已经记录的姓名。",
"你还记得我的名字吗?",
"我叫什么?",
"我的姓名是什么?",
"怎么称呼我?",
"我的名字呢?",
"你记得我叫什么吗?",
"我在这里登记的名字是什么?",
"我在这里叫什么",
"你这里记录的我叫什么",
"我在你这边叫什么",
"你这边怎么称呼我",
"系统里记录的我的名字是什么",
),
"holdout_queries": (
"我是谁?",
"你知道我是谁吗?",
"你还记得我是谁吗?",
"我在你这里叫什么?",
),
},
"project": {
"facts": (
"我正在开发{value}项目。",
"请记住,我当前负责的项目是{value}。",
"我的当前项目名称是{value}。",
"我做的是{value}",
"项目:{value}",
"最近在搞{value}",
"我手头做{value}",
),
"queries": (
"我正在开发什么项目?",
"请查询我当前的项目。",
"我之前说过正在做什么吗?",
"我最近在做什么?",
"我手头的项目叫什么?",
"我在忙哪个项目?",
"我最近搞的是什么?",
"手上的活是什么项目?",
),
"holdout_queries": (
"我现在主要做什么?",
"我最近在开发哪一个东西?",
"之前提到的项目是什么?",
),
},
"plan": {
"facts": (
"我计划在{value}完成这件事。",
"请记住我的计划:{value}。",
"我的下一步安排是{value}。",
"计划:{value}",
"我打算{value}",
"准备在{value}完成",
"安排:{value}",
"待办:{value}",
),
"queries": (
"我接下来的计划是什么?",
"请查询我记录过的安排。",
"我之前说过下一步要做什么?",
"我下一步准备做什么?",
"我安排在什么时候完成?",
"我接下来怎么安排?",
"我的安排是什么?",
"我有什么计划?",
),
"holdout_queries": (
"我接下来打算怎么安排?",
"我已经计划好的事情是什么?",
),
},
"constraint": {
"facts": (
"这个项目的重要约束是{value}。",
"请记住这个开发约束:{value}。",
"以后处理这个项目时必须遵守:{value}。",
"要求:{value}",
"必须{value}",
"别忘了:{value}",
),
"queries": (
"这个项目的重要约束是什么?",
"请查询我记录的开发约束。",
"之前说过这个项目需要遵守什么吗?",
"这个项目有什么限制?",
"开发时需要注意哪条规则?",
"有哪些要求不能忘?",
),
"holdout_queries": (
"这个项目有哪些不能违反的要求?",
"我之前定下的开发规则是什么?",
),
},
"work_code": {
"facts": (
"我的工作地点代号是{value}。",
"请记住,我的工作地点代号为{value}。",
"以后如果问到工作地点,请记住代号{value}。",
"工作地点:{value}",
"地点编号{value}",
"我在{value}办公",
"办公地点:{value}",
"工作地点编号:{value}",
),
"queries": (
"我的工作地点代号是什么?",
"请告诉我已经记录的工作地点代号。",
"我之前说过的工作地点代号是多少?",
"工作地点对应哪个代号?",
"我工作的地方编号是什么?",
"我办公地点是哪儿?",
"我在哪办公?",
"我在哪工作?",
"工作地点是哪儿?",
),
"holdout_queries": (
"我的办公地点编号是多少?",
"我在哪个工作地点?",
),
},
"fruit": {
"facts": (
"我最喜欢的水果是{value}。",
"请记住我的水果偏好:我喜欢{value}。",
"我的个人偏好是最喜欢吃{value}。",
"我爱吃{value}",
"水果偏好:{value}",
"我喜欢{value}",
),
"queries": (
"我最喜欢吃什么水果?",
"请查询我记录过的水果偏好。",
"我之前告诉你的水果喜好是什么?",
"我平时爱吃哪种水果?",
"我的水果口味偏好是什么?",
"我爱吃哪种?",
),
"holdout_queries": (
"我喜欢吃哪一类水果?",
"哪种水果是我的首选?",
),
},
"pet": {
"facts": (
"我养的宠物名字叫{value}。",
"请记住,我的宠物是{value}。",
"我的宠物信息:名字是{value}。",
"宠物是{value}",
"我养了{value}",
"宠物:{value}",
),
"queries": (
"我养的宠物叫什么名字?",
"请查询我的宠物姓名。",
"你记得我的宠物是谁吗?",
"我家的宠物叫什么?",
"我的宠物是哪一只?",
"我养的是什么?",
),
"holdout_queries": (
"我养了什么动物?",
"我的宠物信息是什么?",
),
},
"editor": {
"facts": (
"我平时最常用的编辑器是{value}。",
"记住我的开发工具偏好:编辑器使用{value}。",
"我的编程编辑器偏好为{value}。",
"我用{value}写代码",
"编辑器:{value}",
"开发工具是{value}",
"代码用{value}",
"工具:{value}",
"编程工具:{value}",
"写代码用{value}",
),
"queries": (
"我最常用哪个编辑器?",
"请查询我的开发工具偏好。",
"我的编程编辑器是什么?",
"我平时用什么编辑器?",
"我习惯用哪款开发工具?",
"我写代码用什么?",
"我用什么写代码?",
"我平时写程序用什么工具?",
"我编程时使用什么工具?",
"写代码用的是哪款工具?",
"我用什么工具编程?",
),
"holdout_queries": (
"我写代码通常使用什么工具?",
"我常用的 IDE 是哪个?",
),
},
"city": {
"facts": (
"我现在长期居住在{value}。",
"请记住,我的常住城市是{value}。",
"我的个人资料显示常住地为{value}。",
"我住在{value}",
"常住地:{value}",
"我在{value}生活",
),
"queries": (
"我的常住城市是哪里?",
"请查询我的居住地。",
"我平时住在哪座城市?",
"我长期住在哪里?",
"我的居住城市是什么?",
"我现在住哪儿?",
),
"holdout_queries": (
"我现在定居在哪儿?",
"我的长期住址城市是哪座?",
),
},
"timezone": {
"facts": (
"我的常用时区是{value}。",
"请把我的时区偏好记为{value}。",
"个人资料:我的时区设置为{value}。",
"时区:{value}",
"我在{value}时区",
"本地时区是{value}",
),
"queries": (
"我的常用时区是什么?",
"请查询我的时区设置。",
"我使用哪个时区?",
"我平时按哪个时区生活?",
"我的时间设置是哪一个时区?",
"我所在的时区是什么?",
),
"holdout_queries": (
"我的本地时间属于哪个时区?",
"我应该使用什么时区?",
),
},
}
VALUES = {
"name": ("林浩", "小明", "周宁", "陈雪", "Alice", "Zoe", "X7"),
"project": ("星火记忆", "自然语言记忆", "Qwen 架构实验", "个人助手"),
"plan": ("下周五", "今晚八点", "本周末", "明天上午"),
"constraint": ("不要删除用户数据", "使用简洁中文", "保持原版能力", "优先保证可恢复"),
"work_code": ("R7", "K9", "蓝鲸-47", "M2"),
"fruit": ("红富士苹果", "阳光玫瑰葡萄", "海南芒果", "脆甜梨"),
"pet": ("豆包", "团子", "可可", "雪球"),
"editor": ("VS Code", "Neovim", "PyCharm", "Emacs"),
"city": ("上海", "成都", "深圳", "杭州"),
"timezone": ("Asia/Shanghai", "UTC+8", "Europe/London", "America/Los_Angeles"),
}
HARD_NEGATIVE_QUERIES = (
"你是谁",
"你叫什么",
"请介绍你自己",
"你能做什么",
"你的名字是什么",
"今天天气怎么样",
"帮我写一段代码",
"Python是什么",
"解释一下这个概念",
)
def _chat_ids(tokenizer, text: str) -> tuple[torch.Tensor, torch.Tensor]:
encoded = tokenizer.apply_chat_template(
[{"role": "user", "content": text}],
tokenize=True,
add_generation_prompt=True,
return_tensors="pt",
return_dict=True,
enable_thinking=False,
)
ids = encoded["input_ids"]
mask = encoded.get("attention_mask", torch.ones_like(ids))
return ids, mask
def _plain_ids(tokenizer, text: str) -> tuple[torch.Tensor, torch.Tensor]:
encoded = tokenizer(text, add_special_tokens=False, return_tensors="pt")
ids = encoded["input_ids"]
mask = encoded.get("attention_mask", torch.ones_like(ids))
return ids, mask
def _training_query_candidates(attribute: str) -> list[str]:
queries = list(ATTRIBUTES[attribute]["queries"])
queries.extend(
query.rstrip("??。!!,,")
for query in ATTRIBUTES[attribute]["queries"]
if query.rstrip("??。!!,,")
)
return list(dict.fromkeys(queries))
def make_pairs(seed: int, count: int) -> list[dict[str, object]]:
rng = random.Random(seed)
keys = list(ATTRIBUTES)
positive_records: list[dict[str, object]] = []
# Cover every short/long key form and every train-time query form before
# falling back to random samples. This prevents the optimizer from
# seeing a mostly easy subset of the cross-product.
for attribute in keys:
for value in VALUES[attribute][:2]:
for fact_template in ATTRIBUTES[attribute]["facts"]:
for query in _training_query_candidates(attribute):
positive_records.append(
{
"fact": fact_template.format(value=value),
"query": query,
"label": 1.0,
"attribute": attribute,
}
)
while len(positive_records) < count:
attribute = rng.choice(keys)
value = rng.choice(VALUES[attribute])
positive_records.append(
{
"fact": rng.choice(ATTRIBUTES[attribute]["facts"]).format(value=value),
"query": rng.choice(_training_query_candidates(attribute)),
"label": 1.0,
"attribute": attribute,
}
)
rng.shuffle(positive_records)
positive_records = positive_records[:count]
pairs: list[dict[str, object]] = []
for positive in positive_records:
pairs.append(positive)
attribute = str(positive["attribute"])
negative_attribute = rng.choice([item for item in keys if item != attribute])
negative_query_pool = _training_query_candidates(negative_attribute)
if rng.random() < 0.5:
negative_query_pool = list(negative_query_pool) + list(HARD_NEGATIVE_QUERIES)
negative_query = rng.choice(negative_query_pool)
pairs.append(
{
"fact": positive["fact"],
"query": negative_query,
"label": 0.0,
"attribute": f"{attribute}->{negative_attribute}",
}
)
rng.shuffle(pairs)
return pairs
def make_holdout_queries(seed: int) -> list[dict[str, object]]:
"""Create query paraphrases that are never used during optimization."""
rng = random.Random(seed + 17)
records: list[dict[str, object]] = []
for attribute, definition in ATTRIBUTES.items():
value = rng.choice(VALUES[attribute])
fact = str(definition["facts"][0]).format(value=value)
for query in definition.get("holdout_queries", ()):
records.append({"fact": fact, "query": query, "attribute": attribute})
rng.shuffle(records)
return records
def make_short_fact_holdout() -> list[dict[str, object]]:
"""Stress-test colloquial, short and previously unseen fact strings.
The values are deliberately different from ``VALUES``. This checks that
the retriever recognizes the attribute-bearing language around a value,
instead of memorizing names, cities or project identifiers.
"""
return [
{"fact": "我叫Wpy", "query": "你知道我是谁吗", "attribute": "name"},
{"fact": "我叫Wpy", "query": "我是谁", "attribute": "name"},
{"fact": "我做的是量子账本", "query": "我最近在忙什么项目", "attribute": "project"},
{"fact": "准备在周三完成", "query": "我的待办安排是什么", "attribute": "plan"},
{"fact": "必须保留原始数据", "query": "有什么要求必须遵守", "attribute": "constraint"},
{"fact": "地点编号Z3", "query": "我在哪儿办公", "attribute": "work_code"},
{"fact": "我喜欢白桃", "query": "我爱吃什么", "attribute": "fruit"},
{"fact": "我养了阿福", "query": "家里养的是什么动物", "attribute": "pet"},
{"fact": "我用Cursor写代码", "query": "我编程时用哪个工具", "attribute": "editor"},
{"fact": "我住在苏州", "query": "我人住哪座城", "attribute": "city"},
{"fact": "时区:Asia/Tokyo", "query": "本地采用哪个时区", "attribute": "timezone"},
]
def make_hard_negative_records() -> list[dict[str, object]]:
"""Build explicit non-memory queries for every representative key form."""
records: list[dict[str, object]] = []
for attribute, definition in ATTRIBUTES.items():
key_templates = (definition["facts"][0], definition["facts"][3])
for value in VALUES[attribute][:2]:
for fact_template in key_templates:
fact = fact_template.format(value=value)
for query in HARD_NEGATIVE_QUERIES:
records.append(
{
"fact": fact,
"query": query,
"label": 0.0,
"attribute": attribute,
}
)
return records
@torch.inference_mode()
def encode_records(model, tokenizer, records, *, batch_size: int, device):
"""Encode pairs without padding-induced representation drift.
The Qwen linear-attention path is not perfectly invariant to right-padded
batches. Runtime retrieval encodes one query/fact at a time, so training
must use the same effective sequence shape. Grouping by exact token
length keeps batching efficient while guaranteeing that every row is
unpadded and therefore matches single-example inference.
"""
query_rows: list[torch.Tensor] = []
key_rows: list[torch.Tensor] = []
for record in records:
# Runtime retrieval receives the raw user query, not a chat-template
# wrapped prompt. Keeping this protocol identical is essential:
# otherwise a retriever can score its offline test set well while
# failing on the actual restart path.
q_ids, _ = _plain_ids(tokenizer, str(record["query"]))
k_ids, _ = _plain_ids(tokenizer, str(record["fact"]))
query_rows.append(q_ids[0])
key_rows.append(k_ids[0])
def encode_without_padding(rows: list[torch.Tensor]) -> torch.Tensor:
vectors: list[Optional[torch.Tensor]] = [None] * len(rows)
groups: dict[int, list[int]] = {}
for index, row in enumerate(rows):
groups.setdefault(int(row.numel()), []).append(index)
for indices in groups.values():
for start in range(0, len(indices), batch_size):
selected = indices[start : start + batch_size]
length = rows[selected[0]].numel()
batch = torch.stack([rows[index] for index in selected]).to(device)
mask = torch.ones((len(selected), length), dtype=torch.long, device=device)
encoded = model._encode_model_key(batch, mask).cpu()
for row_index, vector in zip(selected, encoded):
vectors[row_index] = vector
if any(vector is None for vector in vectors):
raise RuntimeError("failed to encode every memory record")
return torch.stack([vector for vector in vectors if vector is not None])
return encode_without_padding(query_rows), encode_without_padding(key_rows)
@torch.inference_mode()
def evaluate_retriever(model, query_vectors, key_vectors, query_attributes, key_attributes) -> dict[str, float]:
"""Measure attribute retrieval on held-out paraphrases."""
scores = model.text_retriever(
query_vectors,
key_vectors.unsqueeze(0).expand(query_vectors.shape[0], -1, -1),
)
key_attributes = list(key_attributes)
positive_mask = torch.tensor(
[[query_attribute == key_attribute for key_attribute in key_attributes]
for query_attribute in query_attributes],
dtype=torch.bool,
device=scores.device,
)
positive_scores = scores.masked_fill(~positive_mask, torch.finfo(scores.dtype).min).max(dim=1).values
negative_scores = scores.masked_fill(positive_mask, torch.finfo(scores.dtype).min).max(dim=1).values
positive_probabilities = torch.sigmoid(positive_scores)
negative_probabilities = torch.sigmoid(negative_scores)
predicted = scores.argmax(dim=1).detach().cpu().tolist()
predicted_attributes = [key_attributes[index] for index in predicted]
accuracy = sum(
predicted_attribute == query_attribute
for predicted_attribute, query_attribute in zip(predicted_attributes, query_attributes)
) / max(1, len(query_attributes))
return {
"accuracy": float(accuracy),
"positive_score_mean": float(positive_scores.mean().detach().cpu()),
"negative_score_mean": float(negative_scores.mean().detach().cpu()),
"margin_mean": float((positive_scores - negative_scores).mean().detach().cpu()),
"positive_probability_min": float(positive_probabilities.min().detach().cpu()),
"negative_probability_max": float(negative_probabilities.max().detach().cpu()),
"positive_threshold_recall": float((positive_probabilities >= 0.5).float().mean().detach().cpu()),
}
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--model-path", default=".")
parser.add_argument("--base-adapter", default="dynamic_memory_lab/qwen_memory_adapter_native_v3")
parser.add_argument(
"--output-adapter",
default="dynamic_memory_lab/qwen_memory_adapter_natural_controller_v1",
)
parser.add_argument("--steps", type=int, default=2200)
parser.add_argument("--pair-count", type=int, default=1600)
parser.add_argument("--batch-size", type=int, default=32)
parser.add_argument("--lr", type=float, default=2e-4)
parser.add_argument("--seed", type=int, default=20260904)
parser.add_argument("--no-4bit", action="store_true")
args = parser.parse_args()
random.seed(args.seed)
torch.manual_seed(args.seed)
tokenizer = load_tokenizer(args.model_path)
config = load_memory_config(args.base_adapter)
config.natural_language_memory = True
config.persistent_memory = False
config.direct_logit_scale = 0.0
model = load_qwen_dynamic(
args.model_path,
memory_config=config,
load_in_4bit=not args.no_4bit,
)
model.load_memory_adapter(args.base_adapter, strict=True)
model.eval()
if model.text_retriever is None:
raise RuntimeError("natural-language retriever was not created")
model.text_retriever.train()
device = model._find_layer_device()
pairs = make_pairs(args.seed, args.pair_count)
training_records = [pair for pair in pairs if float(pair["label"]) == 1.0]
query_tensor, key_tensor = encode_records(
model,
tokenizer,
training_records,
batch_size=args.batch_size,
device=device,
)
query_tensor = query_tensor.to(device)
key_tensor = key_tensor.to(device)
training_attributes = [str(record["attribute"]) for record in training_records]
attribute_names = list(ATTRIBUTES)
attribute_indices = {
attribute: [index for index, item in enumerate(training_attributes) if item == attribute]
for attribute in attribute_names
}
if any(not indices for indices in attribute_indices.values()):
raise RuntimeError("training data did not cover every memory attribute")
holdout_records = make_holdout_queries(args.seed)
holdout_query_tensor, holdout_key_tensor = encode_records(
model,
tokenizer,
holdout_records,
batch_size=args.batch_size,
device=device,
)
holdout_query_tensor = holdout_query_tensor.to(device)
holdout_key_tensor = holdout_key_tensor.to(device)
holdout_attributes = [str(record["attribute"]) for record in holdout_records]
short_holdout_records = make_short_fact_holdout()
short_holdout_query_tensor, short_holdout_key_tensor = encode_records(
model,
tokenizer,
short_holdout_records,
batch_size=args.batch_size,
device=device,
)
short_holdout_query_tensor = short_holdout_query_tensor.to(device)
short_holdout_key_tensor = short_holdout_key_tensor.to(device)
short_holdout_attributes = [str(record["attribute"]) for record in short_holdout_records]
hard_negative_records = make_hard_negative_records()
hard_negative_query_tensor, hard_negative_key_tensor = encode_records(
model,
tokenizer,
hard_negative_records,
batch_size=args.batch_size,
device=device,
)
hard_negative_query_tensor = hard_negative_query_tensor.to(device)
hard_negative_key_tensor = hard_negative_key_tensor.to(device)
optimizer = torch.optim.AdamW(model.text_retriever.parameters(), lr=args.lr, weight_decay=0.01)
rng = random.Random(args.seed + 1)
for step in range(1, args.steps + 1):
selected_indices = [
rng.choice(attribute_indices[attribute]) for attribute in attribute_names
]
selected_indices.extend(
rng.randrange(query_tensor.shape[0])
for _ in range(max(0, args.batch_size - len(selected_indices)))
)
indices = torch.tensor(
selected_indices[: args.batch_size],
dtype=torch.long,
device=device,
)
batch_queries = query_tensor[indices]
batch_keys = key_tensor[indices]
batch_attributes = [training_attributes[index] for index in indices.detach().cpu().tolist()]
pair_logits = model.text_retriever(
batch_queries,
batch_keys.unsqueeze(0).expand(batch_queries.shape[0], -1, -1),
)
positive_mask = torch.tensor(
[[left == right for right in batch_attributes] for left in batch_attributes],
dtype=torch.bool,
device=device,
)
positive_logsum = torch.logsumexp(
pair_logits.masked_fill(~positive_mask, torch.finfo(pair_logits.dtype).min),
dim=1,
)
contrastive_loss = -(positive_logsum - torch.logsumexp(pair_logits, dim=1)).mean()
negative_indices = [
rng.choice(
attribute_indices[
rng.choice([name for name in attribute_names if name != attribute])
]
)
for attribute in batch_attributes
]
negative_keys = key_tensor[torch.tensor(negative_indices, dtype=torch.long, device=device)]
positive_logits = pair_logits.diagonal()
negative_logits = model.text_retriever(batch_queries, negative_keys)
hard_negative_logits = model.text_retriever(
hard_negative_query_tensor,
hard_negative_key_tensor,
)
bce_logits = torch.cat((positive_logits, negative_logits), dim=0)
bce_labels = torch.cat(
(
torch.ones_like(positive_logits),
torch.zeros_like(negative_logits),
),
dim=0,
)
classification_loss = F.binary_cross_entropy_with_logits(bce_logits, bce_labels)
hard_negative_loss = F.binary_cross_entropy_with_logits(
hard_negative_logits,
torch.zeros_like(hard_negative_logits),
)
loss = contrastive_loss + 0.5 * classification_loss + 0.75 * hard_negative_loss
optimizer.zero_grad(set_to_none=True)
loss.backward()
torch.nn.utils.clip_grad_norm_(model.text_retriever.parameters(), 1.0)
optimizer.step()
if step == 1 or step % 100 == 0 or step == args.steps:
with torch.inference_mode():
predictions = pair_logits.argmax(dim=1)
batch_accuracy = sum(
batch_attributes[index] == attribute
for index, attribute in zip(
predictions.detach().cpu().tolist(), batch_attributes
)
) / max(1, len(batch_attributes))
holdout_stats = evaluate_retriever(
model,
holdout_query_tensor,
holdout_key_tensor,
holdout_attributes,
holdout_attributes,
)
short_holdout_stats = evaluate_retriever(
model,
short_holdout_query_tensor,
short_holdout_key_tensor,
short_holdout_attributes,
short_holdout_attributes,
)
print(
f"step={step} loss={float(loss.detach()):.5f} "
f"batch_attribute_accuracy={batch_accuracy:.3f} "
f"holdout_accuracy={holdout_stats['accuracy']:.3f} "
f"holdout_margin={holdout_stats['margin_mean']:.3f} "
f"short_fact_accuracy={short_holdout_stats['accuracy']:.3f} "
f"short_fact_margin={short_holdout_stats['margin_mean']:.3f} "
f"short_fact_threshold_recall={short_holdout_stats['positive_threshold_recall']:.3f}"
)
model.text_retriever.eval()
model._text_retriever_ready = True
model.memory_config.persistent_memory = False
output_dir = Path(args.output_adapter)
model.save_memory_adapter(output_dir)
stats = {
"steps": args.steps,
"pair_count": len(pairs),
"positive_training_records": len(training_records),
"hard_negative_records": len(hard_negative_records),
"holdout_records": len(holdout_records),
"source_adapter": str(args.base_adapter),
"retriever": "qwen_hidden_pair_mlp_multisample_contrastive_with_hard_negatives",
"holdout": evaluate_retriever(
model,
holdout_query_tensor,
holdout_key_tensor,
holdout_attributes,
holdout_attributes,
),
"short_fact_holdout": evaluate_retriever(
model,
short_holdout_query_tensor,
short_holdout_key_tensor,
short_holdout_attributes,
short_holdout_attributes,
),
}
(output_dir / "retriever_training.json").write_text(
json.dumps(stats, ensure_ascii=False, indent=2),
encoding="utf-8",
)
print(json.dumps(stats, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()