Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@

- [ ] `Scripts/format-check.sh`
- [ ] `Scripts/verify-vendor.sh`
- [ ] `Scripts/repository-policy-check.sh`
- [ ] `swift test --package-path RawGeoCore`
- [ ] `swift test --package-path MetadataInfrastructure`
- [ ] RawGeoSync Debug 构建
- [ ] RawGeoSync Release 构建与 `xcodebuild analyze`

## 数据与安全

- [ ] 未提交真实照片、GPX、XMP、坐标、绝对路径或秘密
- [ ] 未改变 RAW 只读和 XMP sidecar 边界
- [ ] 已有 GPS、取消、失败、重复运行行为已检查
- [ ] 若修改了元数据写入,已补充事务或契约测试
- [ ] 若修改了匹配规则,已覆盖阈值边界、来源优先级、单跳传播和强冲突
- [ ] 真实 dry-run 报告、路径、文件名、坐标和哈希清单未上传

## 其他

Expand Down
41 changes: 18 additions & 23 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,40 +8,35 @@ on:
permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
macos:
name: macOS checks
name: Repository, tests, build and analyze
runs-on: macos-15
timeout-minutes: 25
timeout-minutes: 45
env:
DEVELOPER_DIR: /Applications/Xcode_26.3.app/Contents/Developer
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0

- name: Show toolchain
run: |
xcode-select -p
xcodebuild -version
swift --version

- name: Verify bundled ExifTool
run: ./Scripts/verify-vendor.sh

- name: Swift format
run: ./Scripts/format-check.sh

- name: Core tests
run: swift test --package-path RawGeoCore

- name: Metadata tests
run: swift test --package-path MetadataInfrastructure
- name: Full quality gate
run: ./Scripts/ci.sh

- name: Application build
- name: Assert clean checkout
run: |
xcodebuild \
-project RawGeoSync.xcodeproj \
-scheme RawGeoSync \
-configuration Debug \
-destination 'platform=macOS,arch=arm64' \
-derivedDataPath .local/CI-DerivedData \
CODE_SIGNING_ALLOWED=NO \
build
if [[ -n "$(git status --porcelain --untracked-files=all)" ]]; then
git status --short --untracked-files=all
exit 1
fi
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,31 @@

本文件记录面向用户和贡献者的重要变化。版本遵循语义化版本的意图;在 1.0.0 前,MVP 的行为和界面仍可能调整。

## [0.2.0] - 2026-08-10

### 新增

- 引入多来源位置证据模型和稳定 reason code,保留候选来源、粒度、规则、传播跳数与确认状态。
- 按 manual、直接传感器、同照片、GPX、相机定位、burst、序列、有界停留、跨相机和活动区分层回退,提高缺轨照片的可解释覆盖率。
- 支持相机时钟偏移建议和多相机锚点,同时阻断循环传播、航班边界与跨活动区污染。
- 新增接受 GPX 目录和照片目录的全量只读回归契约,记录 wall time、峰值内存并用前后 SHA-256 快照证明原目录未变。
- 新增本地与 GitHub Actions 一致的仓库策略、Debug/Release 测试、应用构建和静态分析门禁。
- 照片目录改为递归扫描;DNG、JPEG、TIFF 和相邻 XMP 可作为只读证据,只有专有 RAW 能成为 XMP 写入目标。
- “全选照片”按当前筛选直接切换照片写入勾选,覆盖可靠、待确认、粗略和未匹配分类。
- 大型图库按批读取元数据,单个损坏媒体会被隔离并报告,不再使整批分析失败。
- 覆盖优先模式将活动按 30 分钟照片间隔拆成会话,并把粗粒度区域限制在各会话时间包络内,避免多城市旅行日被单一日中心污染。

### 安全与隐私

- 不再在文档或脚本中记录可关联特定私有样本的路径、文件名、位置或黄金结果;PR 可分享脱敏聚合数量,本地真实报告不得作为 CI artifact 上传。
- 明确区分源提供的水平精度和规则推断范围。源无 hacc 时保持 unknown,不显示伪米级误差。
- 强候选位置冲突、传播超过一跳或证据循环时禁止自动写入;活动区等弱候选不能覆盖强候选。

### 兼容性说明

- `Scripts/real-sample-smoke.sh` 保留为兼容入口,但转交新的 `real-data-regression.sh`;旧的单 GPX 文件和私有黄金计数接口不再支持。
- 缺少回归 CLI 能力时普通本地检查会明确标为 skipped,发布门禁则视为失败。

## [0.1.0] - 2026-08-08

### 新增
Expand Down
16 changes: 13 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,26 @@

## 数据与隐私

不要提交真实 NEF、其他照片、GPX、XMP、地图截图或含坐标的日志。真实样本只能放在被 Git 忽略的 `.local/` 下,并且写入测试必须使用副本。合成夹具应固定使用虚构时间和位置。
不要提交真实 NEF、其他照片、GPX、XMP、地图截图或含坐标的日志。不要在源码、文档、Issue 或 PR 中写入真实绝对路径、文件名和精确时间线。真实样本只能通过参数引用;本地报告放在被 Git 忽略的 `.local/` 下,写入测试必须使用副本。合成夹具应固定使用虚构时间和位置。

RawGeoSync 不上传照片、轨迹或坐标。新增网络请求、遥测、反向地理编码或云端依赖需要单独的设计讨论和用户同意。

## 修改流程

1. 从 `main` 创建主题分支,分支名使用 `agent/<简短描述>` 或 `feature/<简短描述>`。
2. 先修改核心模型和测试,再修改适配器或界面;不要让 SwiftUI 视图直接依赖 GPX/XML 或 ExifTool 细节。
3. 运行 `Scripts/format-check.sh`、`Scripts/verify-vendor.sh` 和 `Scripts/test.sh`。
3. 运行 `Scripts/repository-policy-check.sh`、`Scripts/format-check.sh`、`Scripts/verify-vendor.sh` 和 `Scripts/test.sh`。合并前运行一次 `Scripts/ci.sh`。
4. 提交信息使用简短、可读的动词开头,例如 `feat: ...`、`fix: ...`、`test: ...`、`docs: ...`。
5. Pull Request 中说明行为变化、数据安全影响和已运行的验证命令。

匹配规则的阈值、来源优先级、传播边界或 confidence 语义发生变化时,必须同步更新 ADR、reason code 测试和 dry-run 报告契约。证据等级不是概率;只有来源明确给出 hacc 时才能展示传感器精度。

## 真实数据回归

`Scripts/real-data-regression.sh` 是唯一支持的原始数据只读入口。它接收 GPX 目录和照片目录,不接受写入目标;报告和前后哈希清单只能写入 `.local/` 或另一个与输入目录无包含关系的目录。

真实回归结果不得上传。PR 中只写脱敏聚合信息,例如照片数、轨迹点数、规则计数、wall time 和峰值内存。需要测试 XMP 写入时,先创建新的 `.local/write-regression/` 副本,并人工核对解析后的目标路径不是原始目录。

## 元数据边界

- RAW 文件永远不能作为写入目标。
Expand All @@ -36,7 +44,9 @@ RawGeoSync 不上传照片、轨迹或坐标。新增网络请求、遥测、反

- [ ] 没有提交真实照片、GPX、XMP、绝对路径或秘密
- [ ] 新增策略分支有合成单元测试
- [ ] 候选来源、reason code、传播跳数和冲突行为可解释
- [ ] 未把规则推断范围写成传感器精度或概率
- [ ] 取消、失败、冲突和重复运行仍然安全
- [ ] RAW SHA-256 在相关测试前后保持不变
- [ ] 格式检查、包测试和应用构建通过
- [ ] 仓库策略、格式检查、包测试、应用构建和静态分析通过
- [ ] README、CHANGELOG 或决策文档已同步更新
106 changes: 106 additions & 0 deletions Docs/Decisions/0002-matching-v2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# ADR 0002:匹配 v2 的证据链、分层回退与冲突边界

状态:已接受(2026-08-09)

## 背景

GPX 记录器可能按位移而不是固定时间采样;静止、后台暂停、跨城交通和航班会形成从数分钟到数天的空洞。照片还可能携带独立 GPS、相机最近定位、同一拍摄序列或另一台相机的锚点。仅按“最近时间点”选择位置,会把活动区或城市之间的轨迹错误传播到照片。

v2 的目标是在尽可能提高覆盖率的同时,使每个结果都能回答三个问题:位置来自哪里、经过了几次传播、为何允许或拒绝自动采用。`confidence` 表示证据等级,不是统计概率;没有传感器精度时不得生成看似精确的米级误差。

## 决策

### 候选来源优先级

同一照片可产生多个 `LocationCandidate`,来源优先级从高到低为:

1. `manualOverride`
2. `directSensor`
3. `sameAsset`
4. `gpxExact`
5. `gpxInterpolated`
6. `embeddedFreshFix`
7. `embeddedTrackFix`
8. `burstPropagation`
9. `sequencePropagation` / `stationaryBounded`
10. `crossCamera`
11. `activityRegion`

低层候选只能补足缺失,不得覆盖可比较的高层候选。人工位置具有最高优先级,但必须保留其人工来源,不能伪装成传感器观测。

只有 `high` 或用户明确给出的 `manual` 候选可以形成无需确认的终态;`medium` 与 `low` 一律进入待确认。因而 burst、照片序列、有界停留和跨相机传播即使满足各自门槛,也不会显示为绿色可靠结果。

来源到输入的映射如下:`directSensor` 是目标曝光自身的直接定位观测;`sameAsset` 是同一照片的 sidecar、渲染衍生物或显式同资产关系提供的位置;`embeddedFreshFix` 是相机内嵌且仍在 fresh-age 门槛内的单次 fix;`embeddedTrackFix` 是由一组相机内嵌 fix 构成的逻辑轨迹候选。`gpxExact` 与 `gpxInterpolated` 只来自外部 GPX 逻辑轨迹。

### 证据与可追溯性

候选必须携带 `sourceKind`、`granularity`、`confidence`、稳定的 `ruleID` 和 `LocationEvidence`。证据至少记录:来源标识、相关照片标识、UTC 观测时间、定位年龄、源提供的水平精度、规则推断范围、传播跳数、是否发生循环及脱敏备注。

- `horizontalAccuracyMeters` 只表示源明确提供的传感器精度;源未提供时为 `nil`,界面显示“源未提供定位精度”。
- `estimatedRadiusMeters` 只能表示由锚点离散度或活动区边界计算出的保守推断范围,必须与传感器精度分开展示;无可验证几何边界时为 `nil`。
- 传播只允许从无需复核的 primary anchor 出发,最多一跳。由传播得到的候选不能继续充当传播锚点。
- `isCircular` 为真或证据图形成环时,候选不得自动采用。

primary anchor 限于 `manualOverride` 到 `embeddedTrackFix` 这些来源中无需复核、且 confidence 为 `manual` 或 `high` 的候选。当前规则不会产生无需复核的 `medium` 候选。“可比较强候选”同样指来源位于这一闭区间且无需复核的候选;不同来源之间也必须比较。人工覆盖由用户显式决定,因此不参与强候选距离冲突;其余可比较强候选都受冲突门槛约束。

当前 confidence 映射是确定的:有效人工覆盖为 `manual`;未触发复核的直接传感器、同资产、GPX exact、密集 GPX 插值、fresh embedded fix 和 embedded fix 轨迹为 `high`;burst、sequence、有界停留为 `medium` 且始终复核;稀疏插值、nearest、跨相机和自动学习活动区为 `low` 且始终复核。直接或 embedded 观测的源水平精度超过 500 米、同资产关系形成循环,都会降为 `low` 并要求复核。

### 默认规则

| 规则 | v2 默认边界 | 生成与采用约束 |
| --- | --- | --- |
| 直接 GPS | 定位年龄不超过 60 秒 | 源有效且无强候选冲突 |
| 相机 fresh fix | 定位年龄不超过 120 秒 | 保留原始观测时间和精度 |
| 密集 GPX | 相邻点不超过 60 秒且端点不超过 250 米 | 可作可靠插值 |
| 稀疏 GPX | 相邻点不超过 600 秒 | 只生成待确认插值;其余普通空洞切断会话 |
| 停留候选 | 600 秒到 6 小时且端点不超过 150 米 | 只作待确认候选,不把端点接近当作停留事实 |
| 航班边界 | 推导速度达到 100 米/秒 | 切断普通地面传播与插值 |
| 拍摄 burst | 相邻 30 秒、序号差不超过 3、锚点离散不超过 200 米 | 单跳、非循环,作为待确认候选 |
| 照片序列 | 相邻 300 秒、序号差不超过 50、锚点离散不超过 500 米 | 只作补足,通常需复核 |
| 有界停留 | 双锚跨度不超过 1,800 秒、锚点距离不超过 500 米 | 双锚一致且无活动边界,作为待确认候选 |
| 跨相机 | 时间差不超过 120 秒、锚点一致在 500 米内 | 由强锚或多个独立一致锚点生成;结果仍为 `low` 并待确认 |
| 活动区 | 同一活动内相邻照片不超过 30 分钟形成拍摄会话 | 仅在该会话首末照片时间内作粗粒度补足,不覆盖强候选 |

阈值是规则门槛,不是定位精度。任何可比较的强候选相距超过 1,000 米时,解析结果必须为 `conflict`,不得用优先级悄悄选中一方。1,000 米以内也不等价于一致;各规则仍需满足自身更严格的离散度边界。

### 活动区回退的会话化

活动目录是有用的上下文,但不能直接等同于单一地点。覆盖优先模式先在每个活动内按拍摄时间排序,再以相邻照片超过 30 分钟为边界切成独立拍摄会话。每个区域候选必须携带 `activeFromUTC` 与 `activeToUTC`,解析器只允许它匹配该会话时间包络内的照片,防止旅行日的一个城市代表点覆盖同日其他城市。

会话代表位置按以下顺序建立:

1. 使用从“会话首张照片前 1 小时”到“会话末张照片后 1 小时”的连续闭区间内全部 GPX 点计算代表位置;点集为空或半径超过 150 公里时转入下一层回退。
2. 在用户为本次任务选择的 IANA 时区所定义的同一当地日中,排除明确速度不低于 80 米/秒的点,选择时间最接近会话中点的点,再收集距该点不超过 50 公里的所有同日点,计算城市级代表;推断半径下限为 10 公里。
3. 若当天也缺乏可用点,取时间上紧邻会话起点之前与会话终点之后的两个 GPX 点。仅当两点分别距会话不超过 36 小时、两点相距不超过 1 公里时,生成“相邻日同地点”候选;推断半径下限同为 10 公里。它仍只表示端点一致,不能证明中间从未离开,因此必须复核。

最终半径超过 150 公里的候选仍然丢弃。上述自动学习区域全部为 `low` 证据、需要确认;用户明确放置的活动 pin 才是 `manual`。这些半径是区域覆盖范围,不是相机或手机的定位精度。

所有点集都使用同一确定性代表算法:分别计算纬度和经度中位数,然后在原始点集中选择距离这个中位中心最近的实际轨迹点;若距离相同,以输入的稳定时间/来源顺序为准。将 `n` 个距离升序排列为从 0 开始的数组,`estimatedRadiusMeters` 取索引 `round(0.9 × (n - 1))` 的元素,最小为 1 米。算法不制造轨迹中不存在的新坐标,也不把该统计范围当作传感器精度。

本 ADR 中全部空间距离统一使用半径 6,371,000 米球体上的 haversine 大圆距离;插值使用跨 180 度安全的球面线性插值。阈值边界测试必须调用同一 `GeoMath` 实现,不能混用平面投影距离。

用户活动 pin 在候选模型中仍使用 `activityRegion` 来源,但 `ActivityRegionSource=userPin` 会把 confidence 设为 `manual` 且无需复核;它不会改名为 `manualOverride`,两者分别表示“对单张资产的人工覆盖”和“用户为活动会话指定的区域”。

`embeddedTrackFix` 只由 `cameraEmbedded` 观测构建,时间必须取 GPS fix 自身的 UTC 时间而非照片拍摄时间。构建器按时间及稳定 ID 排序,并按“毫秒时间戳 + 纳度经纬度”去重;随后复用与外部 GPX 相同的规范化、断段、航班边界和匹配规则。exact / 密集插值为 `high`,稀疏插值 / nearest 为 `low` 且需复核。nearest 只在照片距轨迹端点或普通 gap 边界不超过 120 秒时生成;同样接近两个边界且时间差完全相等时形成歧义而不自动选边。匹配优先级是 exact、密集插值、稀疏插值、nearest;轨迹来源之间再按显式 source priority 和候选一致性解决。

### 时钟校准

自动建议相机时钟偏移至少需要三条独立配对。偏移样本的 MAD 不超过 2 秒可标为高证据,不超过 10 秒可标为中等证据;超出时只展示建议和样本,不自动套用。保存的是相机配置和用户决定,不持久化完整位置时间线。

### 覆盖优先模式

默认模式只自动选择无需确认的强证据。覆盖优先模式可依次使用 burst、序列、有界停留、跨相机和活动区候选,但待确认状态不能因“希望每张都有 GPS”而被提升。仍无可接受证据时,用户可以在地图上为一组照片指定手工位置。

写入清单必须保留最终坐标对应的来源层级、规则、证据摘要和确认状态。XMP 只承载照片位置,不能取代本地事务清单中的推断记录。

## 被否决的替代方案

- 固定三分钟或其他固定采样间隔:实际记录机制可能按位移和运动状态自适应。
- 对所有 GPX 空洞线性插值:会穿过停留、离开后返回、城市跳跃和航班边界。
- 直接采用全天或全文件中心点:多活动区日期会产生并不存在的位置。
- 用统一“预计误差”包装所有结果:源没有 hacc 时会制造虚假精度。
- 允许传播候选继续传播:会放大一次错误并形成不可解释的循环证据。

## 后果

匹配结果数量会增加,但自动采用比例不会等量增加;覆盖率和可靠性必须分别报告。模型、界面、dry-run 报告和事务清单都需要支持来源层级与 reason code。规则阈值必须由合成边界测试和只读真实回归共同验证,任何改变都需要更新本 ADR 或后续决策文档。
Loading