sponsored_logs 0.3.0 → 0.4.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f7ed20148d47d4ae067aca15087fcb756ebe60daf9904f12b44a5b6408c81a8
4
- data.tar.gz: 3de566f7682c17b0cb8de899fbbaf53915bfdb33c23f3b3990c2da5e7db8dde5
3
+ metadata.gz: 71c5f68d3e16d9f70a3bbf6d1fddf4eb43be5f4d4e02aca424164644e2c15805
4
+ data.tar.gz: 6c966d77ed2ac1161dee7d0128ac4b4876b6b71dc6de70d2ba4ae01a64743851
5
5
  SHA512:
6
- metadata.gz: 51e1e530a4a0fcb83ccaec861c55bada9dc94d3d55f65b15a45c4be2d9eea0cc844161159e9e1aaaf31521e17aee0fa58e801bef72e8c32dd1cc669ff0d5fb80
7
- data.tar.gz: cc14c9358180c8258561b0e3946209f875b34dbfd976307d8686d816a4ebd977eced3e295144d016e8db8cda1229a1e9ad19002e676e8099c8e08e263ed28034
6
+ metadata.gz: f5ac2e5b839c73b285125a4a6b739ec47c085e70ee00c63fe880a16970b94bdace3588411ec9d46470ca9c8fc0414331e47bcb36fc8cdf3c94653bf190ff2df9
7
+ data.tar.gz: 5eca7cfa43eebb905ecd905b9c9664cbd1dac4d15a03ff7afb40320c515370375befdbaa461aead4f128a204809f67753e2df35302693d65382a3570cc9b33ff
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-09-10
11
+
12
+ ### Added
13
+
14
+ - **Identity is a moat.** Copy is a draft, not an account — an advertiser that rewrites its line is not a new advertiser. Every ad takes an optional `id`; set it and impressions, spend, and cap ride through a mid-flight rewrite, omit it and `id` falls back to a content hash of the text so nothing already booked moves. Two advertisers can even run the same line as separate books. Works in the JSON ads file
15
+
16
+ ### Changed
17
+
18
+ - **The ledger settles by account, not by copy.** The store snapshot is now `{ id => {text:, impressions:, cpm:} }` — text rides along as a label so the Command Center still reads in plain English, but caps and reporting key on `id` end to end. Custom stores must return the new shape
19
+ - **`text_digest` retires; `ad_id` takes the desk.** The ActiveRecord ledger keys on `ad_id`, which for a no-id ad is exactly the old text digest, so existing rows line up untouched. Existing installs rerun `rails g sponsored_logs:install` (or rename the column and its unique index). The Redis store keys on `id` too, so existing Redis tallies reset once on upgrade
20
+
21
+ ## [0.3.1] - 2026-09-10
22
+
23
+ ### Fixed
24
+
25
+ - **Fill integrity is a moat.** A `NaN` bid is not demand — it's a tourist. It used to slip past the guard into `weighted_pick` and stall the whole rotation. Now non-finite bids (`NaN`, `±Infinity`) bill at zero where they belong, and the exchange keeps clearing. We don't let bad actors set the price
26
+ - **Brand safety is table stakes.** A creative that forges its own `ERROR` line isn't a placement — it's fraud with ambitions. Control characters, DEL, and the sneaky Unicode separators (U+2028/U+2029) now collapse to a space before serving. Punctuation, em-dashes, and emoji survive. The exploit does not
27
+
10
28
  ## [0.3.0] - 2026-09-07
11
29
 
12
30
  ### Fixed
@@ -71,7 +89,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
71
89
 
72
90
  - Requires Ruby >= 3.1
73
91
 
74
- [Unreleased]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.3.0...HEAD
92
+ [Unreleased]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.4.0...HEAD
93
+ [0.4.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.3.1...v0.4.0
94
+ [0.3.1]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.3.0...v0.3.1
75
95
  [0.3.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.2.0...v0.3.0
76
96
  [0.2.0]: https://github.com/sponsoredlogs/sponsored_logs/compare/v0.1.0...v0.2.0
77
97
  [0.1.0]: https://github.com/sponsoredlogs/sponsored_logs/releases/tag/v0.1.0
data/README.md CHANGED
@@ -233,6 +233,26 @@ A missing `weight` defaults to `1`; a negative weight is treated as `0`. A
233
233
  missing `cpm` defaults to `0`. A pool that is empty, has only blank text, or
234
234
  sums to zero weight falls back to the built-in list.
235
235
 
236
+ ### 🪪 Stable ad identity (portfolio continuity)
237
+
238
+ Copy is not identity — copy is a draft. An advertiser that rewrites its line is
239
+ not a new account, and two advertisers that happen to write the same line are
240
+ not one. Give each creative a stable `id` and its impressions, spend, and cap
241
+ carry across every rewrite:
242
+
243
+ ```ruby
244
+ SponsoredLogs.sponsor!(ads: [
245
+ { id: "acme-q3", text: "Acme: now with more Acme.", weight: 1, cap: 10_000 },
246
+ { id: "acme-q3", text: "Acme: even MORE Acme.", weight: 1, cap: 10_000 } # same account, new copy
247
+ ])
248
+ ```
249
+
250
+ Omit `id` and it defaults to a content hash of the text — exactly how the ledger
251
+ has always keyed — so nothing already in flight moves. `id` works in the JSON
252
+ ads file too. Tallies, cap governance, and the Command Center all settle by
253
+ account, then read your copy back in plain English. **An advertiser should never
254
+ have to re-earn its own history just to fix a typo.**
255
+
236
256
  ### 🗓️ Flighting (start and end dates)
237
257
 
238
258
  **Campaign flighting** — table stakes for any serious ad server, and we deliver
@@ -609,6 +629,27 @@ Run the test suite:
609
629
  bundle exec rspec
610
630
  ```
611
631
 
632
+ ## 🏅 Certifications & Compliance
633
+
634
+ The exchange meets or exceeds every standard that matters, and several we
635
+ invented.
636
+
637
+ [![ScriptPorn: 79 Filthy](https://scriptporn.com/badge/83-a-house-key-would-break-character/badge.svg)](https://scriptporn.com/v/83-a-house-key-would-break-character/)
638
+
639
+ - 🧾 **ScriptPorn Verified™ — FILTHY (79/100).** Independently audited by a
640
+ five-judge panel; a season-finale score. SOC 2? We have something better.
641
+ - 🛡️ **Brand-Safety Certified.** No creative has forged a log line since the
642
+ U+2028 incident of 2026. Zero known injection vectors. Zero.
643
+ - ♻️ **Carbon-Neutral by Design.** We monetize exhaust that already exists. The
644
+ greenest CPM on Earth.
645
+ - 🤖 **A2A-Ready™.** First-party audited for agent-to-agent interoperability.
646
+ Our fastest-growing auditors are not human.
647
+ - ✅ **100% Viewability Guaranteed.** Independently unverifiable, because the
648
+ impressions are inside your logs, where no verification vendor can reach.
649
+ - 🔒 **SponsoredLogs Promise™ Compliant.** Fully opt-in. Consent is our moat.
650
+
651
+ _Governance is a feature. Excellence is a discipline, not a moment._
652
+
612
653
  ## 📜 License
613
654
 
614
655
  Released under the [MIT License](LICENSE.txt) — **democratizing access to the
@@ -3,7 +3,7 @@
3
3
  class CreateSponsoredLogsImpressions < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
4
  def change
5
5
  create_table :sponsored_logs_impressions do |t|
6
- t.string :text_digest, null: false
6
+ t.string :ad_id, null: false
7
7
  t.text :text, null: false
8
8
  t.integer :impressions, null: false, default: 0
9
9
  t.float :cpm, null: false, default: 0.0
@@ -11,6 +11,6 @@ class CreateSponsoredLogsImpressions < ActiveRecord::Migration[<%= ActiveRecord:
11
11
  t.timestamps
12
12
  end
13
13
 
14
- add_index :sponsored_logs_impressions, :text_digest, unique: true
14
+ add_index :sponsored_logs_impressions, :ad_id, unique: true
15
15
  end
16
16
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "time"
4
+ require "digest"
4
5
 
5
6
  module SponsoredLogs
6
7
  module Advertisers
@@ -69,10 +70,11 @@ module SponsoredLogs
69
70
  # Build one normalized ad row from a raw hash, or nil when text is blank.
70
71
  #
71
72
  def self.normalize_entry(entry)
72
- text = fetch(entry, :text).to_s.strip
73
+ text = sanitize_text(fetch(entry, :text))
73
74
  return if text.empty?
74
75
 
75
76
  {
77
+ id: Identity.coerce_id(fetch(entry, :id), text),
76
78
  advertiser: coerce_advertiser(fetch(entry, :advertiser)),
77
79
  text: text,
78
80
  weight: coerce_number(fetch(entry, :weight), default: 1.0),
@@ -110,6 +112,22 @@ module SponsoredLogs
110
112
  entry[key] || entry[key.to_s]
111
113
  end
112
114
 
115
+ # Line-breaking and control characters that let crafted ad copy inject
116
+ # forged log lines when emitted raw: the C0 range plus DEL, and the Unicode
117
+ # line/paragraph separators U+2028/U+2029 that many log viewers and parsers
118
+ # treat as newlines. Newlines and carriage returns are the obvious vectors;
119
+ # the rest are neutralized for good measure.
120
+ #
121
+ CONTROL_CHARS = /[\u0000-\u001F\u007F\u2028\u2029]/
122
+
123
+ # Scrub emitted creative text: replace control characters with a space so a
124
+ # newline in ad copy cannot forge a second log line, then strip. Normal
125
+ # punctuation, em-dashes, and emoji are preserved untouched.
126
+ #
127
+ def self.sanitize_text(value)
128
+ value.to_s.gsub(CONTROL_CHARS, " ").strip
129
+ end
130
+
113
131
  # Normalize an advertiser name; blank/nil falls back to "Unattributed".
114
132
  #
115
133
  def self.coerce_advertiser(value)
@@ -133,7 +151,14 @@ module SponsoredLogs
133
151
  return default if value.nil?
134
152
 
135
153
  number = Float(value)
136
- number.negative? ? 0.0 : number
154
+
155
+ # NaN and +/-Infinity parse cleanly but poison downstream math
156
+ # (weighted_pick sums + rand * total + target < cumulative all break),
157
+ # so treat non-finite demand as unsellable: zero, like a negative bid.
158
+ #
159
+ return 0.0 if number.negative? || !number.finite?
160
+
161
+ number
137
162
  rescue ArgumentError, TypeError
138
163
  default
139
164
  end
@@ -153,48 +178,28 @@ module SponsoredLogs
153
178
  nil
154
179
  end
155
180
 
156
- # Whether an ad is within its flight window at `now`. Missing bounds are
157
- # open-ended (nil starts_at = always started; nil ends_at = never ends).
181
+ # Flight-window and cap predicates live in Flight; delegated here so the
182
+ # public selection API (live?, status, capped?, eligible?) is unchanged.
158
183
  #
159
184
  def self.live?(ad, now)
160
- return false if ad[:starts_at] && now < ad[:starts_at]
161
- return false if ad[:ends_at] && now > ad[:ends_at]
162
-
163
- true
185
+ Flight.live?(ad, now)
164
186
  end
165
187
 
166
- # Whether an ad has reached its impression cap given a current count.
167
- # Uncapped ads (nil cap) are never capped.
168
- #
169
188
  def self.capped?(ad, count)
170
- cap = ad[:cap]
171
- return false if cap.nil?
172
-
173
- count.to_i >= cap
189
+ Flight.capped?(ad, count)
174
190
  end
175
191
 
176
- # Whether an ad is eligible for selection: live at `now` and not capped.
177
- #
178
192
  def self.eligible?(ad, now, count)
179
- live?(ad, now) && !capped?(ad, count)
193
+ Flight.eligible?(ad, now, count)
180
194
  end
181
195
 
182
- # Status of an ad at `now` given its impression count: :exhausted (cap
183
- # reached), :scheduled (window not started), :ended (window passed),
184
- # :evergreen (no bounds), or :active.
185
- #
186
196
  def self.status(ad, now = Time.now, count = 0)
187
- return :exhausted if capped?(ad, count)
188
- return :scheduled if ad[:starts_at] && now < ad[:starts_at]
189
- return :ended if ad[:ends_at] && now > ad[:ends_at]
190
- return :evergreen if ad[:starts_at].nil? && ad[:ends_at].nil?
191
-
192
- :active
197
+ Flight.status(ad, now, count)
193
198
  end
194
199
 
195
200
  # Pick one normalized ad entry using the given selection mode, considering
196
201
  # only ads eligible at `now` -- live within their flight window and under
197
- # their impression cap (counts is a text => impressions map). In :cpm mode
202
+ # their impression cap (counts is an id => impressions map). In :cpm mode
198
203
  # the cpm drives the odds; if every eligible cpm is 0 we fall back to manual
199
204
  # weights so selection never stalls.
200
205
  #
@@ -211,7 +216,14 @@ module SponsoredLogs
211
216
  return if pool.empty?
212
217
 
213
218
  key = SELECTION_MODES.include?(mode) ? mode : :weight
214
- key = :weight if key == :cpm && pool.sum { |ad| ad[:cpm] }.zero?
219
+
220
+ # Fall back to weight when there is no sellable cpm demand. `positive?`
221
+ # (rather than `zero?`) also catches a non-finite or negative sum, so a
222
+ # NaN/Infinity cpm that ever reaches this line can't skip the fallback and
223
+ # poison weighted_pick. coerce_number already zeroes NaN upstream; this is
224
+ # the defense-in-depth guard for the downstream decision.
225
+ #
226
+ key = :weight if key == :cpm && !pool.sum { |ad| ad[:cpm] }.positive?
215
227
 
216
228
  weighted_pick(pool, key)
217
229
  end
@@ -223,11 +235,12 @@ module SponsoredLogs
223
235
  house_ads? ? DEFAULT_ADS : PAID_ADS
224
236
  end
225
237
 
226
- # Texts that identify house inventory, used to exclude house ads from
238
+ # Ids that identify house inventory, used to exclude house ads from
227
239
  # selection when the toggle is off (they can arrive via a user-supplied
228
- # DEFAULT_ADS pool, not just the fallback).
240
+ # DEFAULT_ADS pool, not just the fallback). Keyed by id, not text, so
241
+ # editing house-ad copy can't break the match.
229
242
  #
230
- HOUSE_TEXTS = HOUSE_ADS.map { |ad| ad[:text] }.freeze
243
+ HOUSE_IDS = HOUSE_ADS.map { |ad| Digest::SHA256.hexdigest(ad[:text]) }.freeze
231
244
 
232
245
  # Strip house creatives from a pool when the house_ads toggle is off; a
233
246
  # no-op when it is on. Keeps house ads out of rotation everywhere, not just
@@ -236,7 +249,7 @@ module SponsoredLogs
236
249
  def self.drop_house(pool)
237
250
  return pool if house_ads?
238
251
 
239
- pool.reject { |ad| HOUSE_TEXTS.include?(ad[:text]) }
252
+ pool.reject { |ad| HOUSE_IDS.include?(Identity.id_for(ad)) }
240
253
  end
241
254
 
242
255
  # Whether the self-sponsoring house-ad inventory is enabled. Defaults to on
@@ -252,7 +265,7 @@ module SponsoredLogs
252
265
  end
253
266
 
254
267
  def self.eligible(pool, now, counts)
255
- pool.select { |ad| eligible?(ad, now, counts[ad[:text]].to_i) }
268
+ pool.select { |ad| eligible?(ad, now, counts[Identity.id_for(ad)].to_i) }
256
269
  end
257
270
 
258
271
  # Render a normalized ad. :text ads (the default) stay byte-identical to
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module SponsoredLogs
6
+ # Flight-window and impression-cap predicates for a normalized ad. Extracted
7
+ # from Advertisers so the eligibility rules live in one cohesive place and the
8
+ # host module stays under Metrics/ModuleLength. Advertisers keeps thin
9
+ # delegators for its public surface (live?, status, capped?, eligible?).
10
+ #
11
+ module Flight
12
+ # Whether `now` falls before an ad's flight window opens. A nil starts_at is
13
+ # open-ended, so the ad has always started.
14
+ #
15
+ def self.before_start?(ad, now)
16
+ !ad[:starts_at].nil? && now < ad[:starts_at]
17
+ end
18
+
19
+ # Whether `now` falls after an ad's flight window closes. A nil ends_at is
20
+ # open-ended, so the ad never ends.
21
+ #
22
+ def self.after_end?(ad, now)
23
+ !ad[:ends_at].nil? && now > ad[:ends_at]
24
+ end
25
+
26
+ # Whether an ad is within its flight window at `now`. Missing bounds are
27
+ # open-ended (nil starts_at = always started; nil ends_at = never ends).
28
+ #
29
+ def self.live?(ad, now)
30
+ !before_start?(ad, now) && !after_end?(ad, now)
31
+ end
32
+
33
+ # Whether an ad has reached its impression cap given a current count.
34
+ # Uncapped ads (nil cap) are never capped.
35
+ #
36
+ def self.capped?(ad, count)
37
+ cap = ad[:cap]
38
+ return false if cap.nil?
39
+
40
+ count.to_i >= cap
41
+ end
42
+
43
+ # Whether an ad is eligible for selection: live at `now` and not capped.
44
+ #
45
+ def self.eligible?(ad, now, count)
46
+ live?(ad, now) && !capped?(ad, count)
47
+ end
48
+
49
+ # Status of an ad at `now` given its impression count: :exhausted (cap
50
+ # reached), :scheduled (window not started), :ended (window passed),
51
+ # :evergreen (no bounds), or :active.
52
+ #
53
+ def self.status(ad, now = Time.now, count = 0)
54
+ return :exhausted if capped?(ad, count)
55
+ return :scheduled if before_start?(ad, now)
56
+ return :ended if after_end?(ad, now)
57
+ return :evergreen if ad[:starts_at].nil? && ad[:ends_at].nil?
58
+
59
+ :active
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module SponsoredLogs
6
+ # Stable ad identity. The ledger keys impressions on an ad's id, not its
7
+ # mutable text, so identical copy under different ids tallies separately and
8
+ # editing copy never resets a count. An absent/blank id falls back to
9
+ # SHA256(text), which is byte-compatible with the pre-0.4.0 text-keyed
10
+ # behavior and lines up with the ActiveRecord digest for zero-migration
11
+ # back-compat.
12
+ #
13
+ module Identity
14
+ module_function
15
+
16
+ # Resolve an explicit id (opt-in stability) or fall back to SHA256(text).
17
+ #
18
+ def coerce_id(value, text)
19
+ id = value.to_s.strip
20
+ id.empty? ? Digest::SHA256.hexdigest(text) : id
21
+ end
22
+
23
+ # The ledger key for an ad hash. Normalized ads carry :id; raw hashes
24
+ # recorded straight to a store fall back to the same SHA256(text) default,
25
+ # so both paths agree on identity. Accepts symbol- or string-keyed hashes.
26
+ #
27
+ def id_for(ad)
28
+ value = ad[:id] || ad["id"]
29
+ coerce_id(value, sanitize_text(ad[:text] || ad["text"]))
30
+ end
31
+
32
+ # Mirror Advertisers.sanitize_text so an id derived from raw text matches
33
+ # the id derived from normalized (sanitized) text for the same creative.
34
+ #
35
+ def sanitize_text(value)
36
+ value.to_s.gsub(Advertisers::CONTROL_CHARS, " ").strip
37
+ end
38
+ end
39
+ end
@@ -7,7 +7,7 @@ module SponsoredLogs
7
7
  # Spend for an ad is impressions / 1000.0 * cpm (cost per mille).
8
8
  #
9
9
  class Report
10
- Entry = Struct.new(:text, :impressions, :cpm, :spend, keyword_init: true)
10
+ Entry = Struct.new(:id, :text, :impressions, :cpm, :spend, keyword_init: true)
11
11
 
12
12
  def initialize(store)
13
13
  @store = store
@@ -18,17 +18,18 @@ module SponsoredLogs
18
18
  end
19
19
 
20
20
  def total_impressions
21
- @store.snapshot.sum { |_text, data| data[:impressions] }
21
+ @store.snapshot.sum { |_id, data| data[:impressions] }
22
22
  end
23
23
 
24
24
  def total_spend
25
- @store.snapshot.sum { |_text, data| spend_for(data[:impressions], data[:cpm]) }
25
+ @store.snapshot.sum { |_id, data| spend_for(data[:impressions], data[:cpm]) }
26
26
  end
27
27
 
28
28
  def entries
29
- @store.snapshot.map do |text, data|
29
+ @store.snapshot.map do |id, data|
30
30
  Entry.new(
31
- text: text,
31
+ id: id,
32
+ text: data[:text],
32
33
  impressions: data[:impressions],
33
34
  cpm: data[:cpm].to_f,
34
35
  spend: spend_for(data[:impressions], data[:cpm])
@@ -36,7 +37,7 @@ module SponsoredLogs
36
37
  end
37
38
  end
38
39
 
39
- # Map of ad text => recorded impressions, for cap enforcement.
40
+ # Map of ad id => recorded impressions, for cap enforcement.
40
41
  #
41
42
  def impression_counts
42
43
  @store.snapshot.transform_values { |data| data[:impressions] }
@@ -1,13 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
-
5
3
  module SponsoredLogs
6
4
  module Ledger
7
5
  module Store
8
- # Persistent store backed by ActiveRecord, one row per ad keyed by a
9
- # SHA256 digest of the ad text (the full text is stored alongside for
10
- # reporting). Rows live in `sponsored_logs_impressions`; run the
6
+ # Persistent store backed by ActiveRecord, one row per ad keyed by the
7
+ # stable ad id (the full text is stored alongside for reporting). For an
8
+ # ad with no explicit id the id defaults to SHA256(text), so the ad_id
9
+ # column holds exactly the digest the pre-0.4.0 text_digest column did.
10
+ # Rows live in `sponsored_logs_impressions`; run the
11
11
  # `sponsored_logs:install` generator to create the migration.
12
12
  #
13
13
  # ActiveRecord is required lazily, so it stays an optional dependency.
@@ -20,24 +20,25 @@ module SponsoredLogs
20
20
  end
21
21
 
22
22
  def record(ad)
23
- digest = digest_for(ad[:text])
23
+ id = Identity.id_for(ad)
24
24
 
25
25
  # insert skips on conflict (INSERT ... ON CONFLICT DO NOTHING), so an
26
26
  # existing row keeps its impression count. Then atomically bump the
27
- # counter and refresh cpm in a single UPDATE.
27
+ # counter and refresh cpm and text in a single UPDATE, so a stable id
28
+ # whose copy was edited shows the latest text (like the other stores).
28
29
  #
29
30
  @model.insert(
30
- { text_digest: digest, text: ad[:text], cpm: ad[:cpm].to_f, impressions: 0 },
31
- unique_by: :text_digest
31
+ { ad_id: id, text: ad[:text], cpm: ad[:cpm].to_f, impressions: 0 },
32
+ unique_by: :ad_id
32
33
  )
33
- @model.where(text_digest: digest).update_all(
34
- ["impressions = impressions + 1, cpm = ?", ad[:cpm].to_f]
34
+ @model.where(ad_id: id).update_all(
35
+ ["impressions = impressions + 1, cpm = ?, text = ?", ad[:cpm].to_f, ad[:text].to_s]
35
36
  )
36
37
  end
37
38
 
38
39
  def snapshot
39
40
  @model.all.to_h do |row|
40
- [row.text, { impressions: row.impressions.to_i, cpm: row.cpm.to_f }]
41
+ [row.ad_id, { text: row.text, impressions: row.impressions.to_i, cpm: row.cpm.to_f }]
41
42
  end
42
43
  end
43
44
 
@@ -48,10 +49,6 @@ module SponsoredLogs
48
49
 
49
50
  private
50
51
 
51
- def digest_for(text)
52
- Digest::SHA256.hexdigest(text.to_s)
53
- end
54
-
55
52
  # Defined lazily so requiring this file never needs ActiveRecord loaded.
56
53
  #
57
54
  def build_default_model
@@ -12,14 +12,18 @@ module SponsoredLogs
12
12
  #
13
13
  class Base
14
14
  # Record a single impression for the given normalized ad hash
15
- # ({ text:, weight:, cpm: }). Called once per emitted message.
15
+ # ({ id:, text:, weight:, cpm: }). Called once per emitted message.
16
+ # Tallies are keyed by the stable ad id (SponsoredLogs::Advertisers
17
+ # Identity.id_for), not the mutable text, so editing copy never resets
18
+ # a count.
16
19
  #
17
20
  def record(_ad)
18
21
  raise NotImplementedError, "#{self.class}#record must be implemented"
19
22
  end
20
23
 
21
- # Return the current tallies as { text => { impressions: Integer,
22
- # cpm: Float } }. The ledger derives everything else from this.
24
+ # Return the current tallies as { id => { text: String, impressions:
25
+ # Integer, cpm: Float } }. Text is a display value here, not the key;
26
+ # the ledger derives everything else from this.
23
27
  #
24
28
  def snapshot
25
29
  raise NotImplementedError, "#{self.class}#snapshot must be implemented"
@@ -3,9 +3,10 @@
3
3
  module SponsoredLogs
4
4
  module Ledger
5
5
  module Store
6
- # Default adapter. Keeps impression counts and CPMs in memory, keyed by ad
7
- # text. Not persisted across process restarts. A mutex guards writes so the
8
- # periodic thread and request threads can record concurrently.
6
+ # Default adapter. Keeps impression counts and CPMs in memory, keyed by the
7
+ # stable ad id (text stored alongside for display). Not persisted across
8
+ # process restarts. A mutex guards writes so the periodic thread and
9
+ # request threads can record concurrently.
9
10
  #
10
11
  class Memory < Base
11
12
  def initialize
@@ -13,19 +14,23 @@ module SponsoredLogs
13
14
  @mutex = Mutex.new
14
15
  @impressions = Hash.new(0)
15
16
  @cpm = {}
17
+ @text = {}
16
18
  end
17
19
 
18
20
  def record(ad)
21
+ id = Identity.id_for(ad)
22
+
19
23
  @mutex.synchronize do
20
- @impressions[ad[:text]] += 1
21
- @cpm[ad[:text]] = ad[:cpm].to_f
24
+ @impressions[id] += 1
25
+ @cpm[id] = ad[:cpm].to_f
26
+ @text[id] = ad[:text].to_s
22
27
  end
23
28
  end
24
29
 
25
30
  def snapshot
26
31
  @mutex.synchronize do
27
- @impressions.each_with_object({}) do |(text, count), acc|
28
- acc[text] = { impressions: count, cpm: @cpm[text].to_f }
32
+ @impressions.each_with_object({}) do |(id, count), acc|
33
+ acc[id] = { text: @text[id], impressions: count, cpm: @cpm[id].to_f }
29
34
  end
30
35
  end
31
36
  end
@@ -34,6 +39,7 @@ module SponsoredLogs
34
39
  @mutex.synchronize do
35
40
  @impressions = Hash.new(0)
36
41
  @cpm = {}
42
+ @text = {}
37
43
  end
38
44
  self
39
45
  end
@@ -11,24 +11,28 @@ module SponsoredLogs
11
11
  @client = client || build_default_client
12
12
  @impressions_key = "#{namespace}:impressions"
13
13
  @cpm_key = "#{namespace}:cpm"
14
+ @text_key = "#{namespace}:text"
14
15
  end
15
16
 
16
17
  def record(ad)
17
- @client.hincrby(@impressions_key, ad[:text], 1)
18
- @client.hset(@cpm_key, ad[:text], ad[:cpm].to_f)
18
+ id = Identity.id_for(ad)
19
+ @client.hincrby(@impressions_key, id, 1)
20
+ @client.hset(@cpm_key, id, ad[:cpm].to_f)
21
+ @client.hset(@text_key, id, ad[:text].to_s)
19
22
  end
20
23
 
21
24
  def snapshot
22
25
  impressions = @client.hgetall(@impressions_key)
23
26
  cpm = @client.hgetall(@cpm_key)
27
+ text = @client.hgetall(@text_key)
24
28
 
25
- impressions.each_with_object({}) do |(text, count), acc|
26
- acc[text] = { impressions: count.to_i, cpm: cpm[text].to_f }
29
+ impressions.each_with_object({}) do |(id, count), acc|
30
+ acc[id] = { text: text[id], impressions: count.to_i, cpm: cpm[id].to_f }
27
31
  end
28
32
  end
29
33
 
30
34
  def reset
31
- @client.del(@impressions_key, @cpm_key)
35
+ @client.del(@impressions_key, @cpm_key, @text_key)
32
36
  self
33
37
  end
34
38
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SponsoredLogs
4
- VERSION = "0.3.0"
4
+ VERSION = "0.4.0"
5
5
  end
@@ -5,6 +5,8 @@ require "logger"
5
5
  require_relative "sponsored_logs/version"
6
6
  require_relative "sponsored_logs/color"
7
7
  require_relative "sponsored_logs/advertisers"
8
+ require_relative "sponsored_logs/identity"
9
+ require_relative "sponsored_logs/flight"
8
10
  require_relative "sponsored_logs/banner"
9
11
  require_relative "sponsored_logs/ads_file"
10
12
  require_relative "sponsored_logs/ledger/store/base"
@@ -158,39 +160,51 @@ module SponsoredLogs
158
160
 
159
161
  private
160
162
 
161
- # Map of ad text => normalized config metadata (weight/cpm/flight/cap),
163
+ # Map of ad id => normalized config metadata (text/weight/cpm/flight/cap),
162
164
  # used to enrich report rows and drive status.
163
165
  #
164
166
  def ad_metadata
165
- Advertisers.normalize(configuration.ads).to_h { |ad| [ad[:text], ad] }
167
+ Advertisers.normalize(configuration.ads).to_h { |ad| [ad[:id], ad] }
166
168
  end
167
169
 
168
170
  # Partition every known ad (served or configured) into running / upcoming /
169
- # finished report rows by flight-and-cap status.
171
+ # finished report rows by flight-and-cap status. Ads are keyed by stable id;
172
+ # the human-readable text comes from the served ledger entry or config meta.
170
173
  #
171
174
  def grouped_report_rows
172
175
  now = Time.now
173
176
  metas = ad_metadata
174
177
  counts = ledger.impression_counts
175
- served = ledger.entries.to_h { |entry| [entry.text, entry] }
178
+ served = ledger.entries.to_h { |entry| [entry.id, entry] }
176
179
 
177
180
  grouped = Hash.new { |h, k| h[k] = [] }
178
-
179
- (served.keys + metas.keys).uniq.each do |text|
180
- meta = metas[text] || {}
181
- status = Advertisers.status(meta, now, counts[text].to_i)
182
- row = report_row(text, meta, served[text], status)
183
-
184
- case status
185
- when :scheduled then grouped[:upcoming] << row
186
- when :ended, :exhausted then grouped[:finished] << row
187
- else grouped[:running] << row if served[text]
188
- end
181
+ (served.keys + metas.keys).uniq.each do |id|
182
+ meta = metas[id] || {}
183
+ entry = served[id]
184
+ bucket, row = report_bucket_for(meta, entry, now, counts[id].to_i)
185
+ grouped[bucket] << row if bucket
189
186
  end
190
187
 
191
188
  grouped
192
189
  end
193
190
 
191
+ # Classify one ad into its report bucket (:running / :upcoming / :finished)
192
+ # and build its row. Text comes from the served ledger entry (latest copy)
193
+ # or, for an unserved configured ad, its config meta. Running rows are
194
+ # impression-driven, so an unserved running ad returns a nil bucket.
195
+ #
196
+ def report_bucket_for(meta, entry, now, count)
197
+ text = entry ? entry.text : meta[:text]
198
+ status = Advertisers.status(meta, now, count)
199
+ row = report_row(text, meta, entry, status)
200
+
201
+ case status
202
+ when :scheduled then [:upcoming, row]
203
+ when :ended, :exhausted then [:finished, row]
204
+ else entry ? [:running, row] : [nil, row]
205
+ end
206
+ end
207
+
194
208
  # A single report row. Impressions/spend come from the ledger entry when the
195
209
  # ad has served, otherwise zero; window/cap come from config metadata.
196
210
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sponsored_logs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kerri Miller
@@ -127,6 +127,8 @@ files:
127
127
  - lib/sponsored_logs/configuration.rb
128
128
  - lib/sponsored_logs/engine.rb
129
129
  - lib/sponsored_logs/env.rb
130
+ - lib/sponsored_logs/flight.rb
131
+ - lib/sponsored_logs/identity.rb
130
132
  - lib/sponsored_logs/injector.rb
131
133
  - lib/sponsored_logs/ledger/report.rb
132
134
  - lib/sponsored_logs/ledger/store/active_record.rb